feat(0036b): NodeGroups admite kind=Group + loader entities

NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.

kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:

  - node_groups_count_for_group  -> SELECT count(*) ...
  - node_groups_page_for_group   -> SELECT id,name,type_ref,status,
                                     updated_at ... LIMIT ? OFFSET ?

Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).

Render en views.cpp ramifica por kind:

  - Table: layout original [id_col + columns + promoted] con doble
    click -> promote/focus.
  - Group: layout [columns fijas] sin promoted. Doble click sobre la
    fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
    afinan UX). Right click ofrece "Focus in Inspector".

main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).

views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).

Nueva API publica:

  views_node_groups_open(app, container_id, kind, ops_db)

Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.

Tests:
  - tests/test_node_groups_loader.py (6 tests) verifica el contrato
    SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
    el loader C++ exactamente (mismo SQL); tambien expuesto como tool
    MCP `group_page` para que Echo pueda inspeccionar Groups.

Resultado:
  - WSL: 89 -> 95 passed
  - Windows: 78+11 -> 84+11 passed
  - Build C++ Windows limpio, sin warnings nuevos.
  - Regresion kind=Table: comportamiento identico (mismo render,
    mismo loader DuckDB).

Refs: issues/0036b-kind-discriminator-and-group-loader.md
This commit is contained in:
2026-05-04 00:52:25 +02:00
parent 2a783187a3
commit d6e13fddc3
8 changed files with 613 additions and 65 deletions
+49
View File
@@ -560,6 +560,38 @@ def cmd_table_page(args) -> None:
"offset": args.offset, "limit": args.limit, "rows": rows})
# ----------------------------------------------------------------------------
# group ops (issue 0036b) — espejo Python del loader C++ kind=Group
# ----------------------------------------------------------------------------
def cmd_group_page(args) -> None:
"""Lista entidades hijas de un Group (entities.group_id = ?).
Espejea exactamente la query del loader C++
`node_groups_page_for_group` para que los tests pytest verifiquen
el contrato SQL (mismo orden de filas, mismas columnas) sin depender
del binario. Util tambien como tool MCP para que el agente Echo
inspeccione el contenido de un Group sin abrir la app.
"""
cn = _connect(_ops_db(), readonly=True)
total = cn.execute(
"SELECT count(*) FROM entities WHERE group_id = ?",
(args.container_id,),
).fetchone()[0]
limit = max(1, min(int(args.limit), 5000))
offset = max(0, int(args.offset))
cur = cn.execute(
"SELECT id, name, type_ref, status, updated_at "
"FROM entities WHERE group_id = ? "
"ORDER BY updated_at DESC LIMIT ? OFFSET ?",
(args.container_id, limit, offset),
)
rows = [dict(r) for r in cur.fetchall()]
cn.close()
_emit({"ok": True, "container": args.container_id, "total": total,
"offset": offset, "limit": limit, "rows": rows})
# ----------------------------------------------------------------------------
# enricher ops
# ----------------------------------------------------------------------------
@@ -841,6 +873,13 @@ MCP_TOOLS = [
"description": "Borra la entidad promovida. La fila DuckDB queda intacta.",
"inputSchema": {"type": "object", "properties": {
"id": {"type": "string"}}, "required": ["id"]}},
{"name": "group_page",
"description": "Lista entidades hijas de un Group (entities.group_id = container_id). Espejea el loader C++ de NodeGroups kind=Group.",
"inputSchema": {"type": "object", "properties": {
"container_id": {"type": "string"},
"offset": {"type": "integer", "default": 0, "minimum": 0},
"limit": {"type": "integer", "default": 200, "minimum": 1, "maximum": 5000}},
"required": ["container_id"]}},
{"name": "enricher_list",
"description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.",
"inputSchema": {"type": "object", "properties": {
@@ -902,6 +941,7 @@ MCP_DISPATCH = {
"table_page": (cmd_table_page, {"offset": 0, "limit": 50}),
"table_promote": (cmd_table_promote, {}),
"table_demote": (cmd_table_demote, {}),
"group_page": (cmd_group_page, {"offset": 0, "limit": 200}),
"enricher_list": (cmd_enricher_list, {"type": None}),
"enricher_run": (cmd_enricher_run, {"node": None, "params": None}),
"query": (cmd_query, {"limit": 100}),
@@ -1093,6 +1133,15 @@ def main() -> None:
sp.add_argument("--limit", type=int, default=50)
sp.set_defaults(fn=cmd_table_page)
# group (issue 0036b)
g = sub.add_parser("group").add_subparsers(dest="op", required=True)
sp = g.add_parser("page",
help="Lista entidades hijas de un Group (group_id=?)")
sp.add_argument("container_id")
sp.add_argument("--offset", type=int, default=0)
sp.add_argument("--limit", type=int, default=200)
sp.set_defaults(fn=cmd_group_page)
# enricher
e = sub.add_parser("enricher").add_subparsers(dest="op", required=True)
sp = e.add_parser("list")