From f259103a5cc68d76f973c13ab0092f8f7a423899 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:55:09 +0000 Subject: [PATCH] El agente puede ver la base de la sala: estructura y filas La tool sql solo cambia la estructura y contesta "listo", asi que el agente adivinaba tablas y columnas, sobre todo en un proyecto adoptado donde migraciones/ esta vacia, y no podia comprobar si algo se guardo. - Tool nueva ver_base: sin argumentos describe tablas, columnas, llaves, RLS y politicas; con una consulta devuelve hasta 50 filas. Todo va a Supabase con read_only, que es quien garantiza que no escribe; ademas se rechaza lo que no empieza como lectura y los SELECT viajan con limit 51. - sql y ver_base devuelven el error de Postgres como error de la tool, no como "error inesperado", y sql sugiere revisar con ver_base. - El prompt pide mirar la base antes de crear tablas o escribir codigo contra ellas, y no copiar datos de personas si no hace falta. - La actividad del chat dice "Revisando la base de datos". - demo:ver-base cubre la tool y entra al CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJ5oT2Xm3VZMvbPAcgKbz4 --- .github/workflows/ci.yml | 1 + package.json | 1 + server/package.json | 1 + server/src/agent/loop.ts | 7 ++ server/src/agent/tools/base.ts | 8 +- server/src/agent/tools/index.ts | 3 +- server/src/agent/tools/sql.ts | 16 ++- server/src/agent/tools/ver-base.ts | 171 +++++++++++++++++++++++++++++ server/src/demos/actividad.ts | 1 + server/src/demos/ver-base.ts | 167 ++++++++++++++++++++++++++++ server/src/engine/actividad.ts | 3 + server/src/index.ts | 21 +++- server/src/supabase.ts | 62 ++++++++++- web/src/App.tsx | 3 + web/src/i18n.tsx | 2 + 15 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 server/src/agent/tools/ver-base.ts create mode 100644 server/src/demos/ver-base.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7d0f1f..092cb7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,4 @@ jobs: - run: npm run demo:politicas - run: npm run demo:actividad - run: npm run demo:modo-de-sala + - run: npm run demo:ver-base diff --git a/package.json b/package.json index baf9d63..79d1389 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "demo:cuentas": "npm run demo:cuentas -w server", "demo:cripto": "npm run demo:cripto -w server", "demo:modo-de-sala": "npm run demo:modo-de-sala -w server", + "demo:ver-base": "npm run demo:ver-base -w server", "demo:modo-persistido": "npm run demo:modo-persistido -w server", "demo:sala-al-escribir": "npm run demo:sala-al-escribir -w server", "demo:carga": "npm run demo:carga -w server --", diff --git a/server/package.json b/server/package.json index 574eed9..b0561b1 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ "demo:variables": "tsx src/demos/variables.ts", "demo:politicas": "tsx src/demos/politicas.ts", "demo:actividad": "tsx src/demos/actividad.ts", + "demo:ver-base": "tsx src/demos/ver-base.ts", "demo:publicar": "tsx src/demos/publicar.ts", "demo:cuentas": "tsx src/demos/cuentas.ts", "demo:cripto": "tsx src/demos/cripto.ts", diff --git a/server/src/agent/loop.ts b/server/src/agent/loop.ts index 07726bb..72d2a0f 100644 --- a/server/src/agent/loop.ts +++ b/server/src/agent/loop.ts @@ -186,6 +186,10 @@ créalo con bash: es tu trabajo, no preguntes por dónde empezar. conectó su base: úsalas desde el código de la app para leer y escribir filas, y usa la tool sql para crear las tablas y sus políticas. Con esa tool tienes todo lo que necesitas, así que NO pidas contraseñas ni llaves a nadie. + Antes de crear o cambiar tablas, y antes de escribir código que las use, mira + con ver_base qué hay: no adivines nombres de tablas ni de columnas. Con ella + también puedes revisar filas para comprobar que algo se guardó; son datos de + personas, así que no los copies al chat ni a archivos si no hace falta. Si no están, di que se conecta desde el panel de Variables, con el botón de Supabase, y ofrece dejar la app andando con datos de prueba mientras tanto. - Trabajando contra Supabase hay tres reglas que NO se negocian: @@ -349,6 +353,8 @@ export async function runAgent(opts: { runner: ToolContext["runner"]; /** Con qué cambia el esquema de la base, si la sala conectó una. */ ejecutarSql?: ToolContext["ejecutarSql"]; + /** Leer la base de la sala en solo lectura (tool ver_base). */ + leerBase?: ToolContext["leerBase"]; /** * El historial tal como va, para que sobreviva si el turno LANZA. * @@ -378,6 +384,7 @@ export async function runAgent(opts: { workspaceDir, runner: opts.runner, ejecutarSql: opts.ejecutarSql, + leerBase: opts.leerBase, emit: callbacks.onToolEvent, agentId: opts.agentId, onWaitStart: opts.onWaitStart, diff --git a/server/src/agent/tools/base.ts b/server/src/agent/tools/base.ts index 40ea9d6..b500294 100644 --- a/server/src/agent/tools/base.ts +++ b/server/src/agent/tools/base.ts @@ -36,7 +36,13 @@ export interface ToolContext { * Y sobre todo: la credencial NUNCA entra al contenedor. El agente pide que se * corra el SQL, y quien lo corre es el server con el permiso de la sala. */ - ejecutarSql?: (sql: string) => Promise; + ejecutarSql?: (sql: string) => Promise; + /** + * Lee la base de la sala en modo solo lectura y devuelve las filas, o nada si + * no hay base. Mismo trato que `ejecutarSql`: capacidad, no credencial. Quien + * garantiza que no escribe es Supabase (`read_only`), no la tool. + */ + leerBase?: (sql: string) => Promise; } export type ToolEvent = diff --git a/server/src/agent/tools/index.ts b/server/src/agent/tools/index.ts index 1b23d7c..284a615 100644 --- a/server/src/agent/tools/index.ts +++ b/server/src/agent/tools/index.ts @@ -3,11 +3,12 @@ import { fsTools } from "./fs.js"; import { bashTool } from "./bash.js"; import { usarAdjuntoTool } from "./adjuntos.js"; import { sqlTool } from "./sql.js"; +import { verBaseTool } from "./ver-base.js"; export * from "./base.js"; /** Todas las tools del agente. */ -export const allTools: Tool[] = [...fsTools, bashTool, usarAdjuntoTool, sqlTool]; +export const allTools: Tool[] = [...fsTools, bashTool, usarAdjuntoTool, sqlTool, verBaseTool]; /** Registro por nombre, para despachar los tool_use. */ export const toolRegistry = new Map(allTools.map((t) => [t.spec.name, t])); diff --git a/server/src/agent/tools/sql.ts b/server/src/agent/tools/sql.ts index 6de328d..9d8dc99 100644 --- a/server/src/agent/tools/sql.ts +++ b/server/src/agent/tools/sql.ts @@ -30,7 +30,8 @@ export const sqlTool: Tool = { "Toda tabla nueva nace con RLS activo, así que crea también sus políticas o la app " + "no va a poder leer nada. Nunca desactives RLS, y no escribas políticas using (true) " + "ni with check (true) para escribir: la base tiene login anónimo, así que cada fila " + - "puede compararse con auth.uid().", + "puede compararse con auth.uid(). Para ver qué tablas y columnas hay, o revisar filas, " + + "usa ver_base.", input_schema: { type: "object", properties: { @@ -76,7 +77,18 @@ export const sqlTool: Tool = { ); } - await ctx.ejecutarSql(sql); + try { + await ctx.ejecutarSql(sql); + } catch (err) { + // Lo que diga Postgres (una tabla que ya existe, una columna que no) es + // justo lo que el agente necesita para corregir. Como `error inesperado` + // parecía una falla de Multi, no de su SQL. + const msg = err instanceof Error ? err.message : String(err); + throw new ToolError( + `la base rechazó el SQL y no se guardó migración: ${msg}. Revisa la estructura ` + + "actual con ver_base antes de reintentar.", + ); + } // La migración se guarda DESPUÉS de que corrió, no antes: un archivo que // describe un cambio que falló es peor que no tenerlo, porque quien rehaga diff --git a/server/src/agent/tools/ver-base.ts b/server/src/agent/tools/ver-base.ts new file mode 100644 index 0000000..c0ccfaf --- /dev/null +++ b/server/src/agent/tools/ver-base.ts @@ -0,0 +1,171 @@ +import { type Tool, ToolError } from "./base.js"; +import { CONSULTA_ESQUEMA } from "../../supabase.js"; + +/** + * Ver la base que la sala conectó: su estructura, o las filas de una consulta. + * + * Por qué existe: la tool `sql` solo cambia la estructura y contesta "listo". + * Sin ver qué tablas y columnas hay, el agente las adivinaba y escribía código + * contra columnas que no existían, sobre todo en un proyecto adoptado, donde + * `migraciones/` está vacía aunque la base no. Tampoco podía comprobar si algo + * se guardó. + * + * Nunca escribe. Quien lo garantiza es Supabase, que corre todo en una + * transacción de solo lectura; lo que se revisa aquí es solo para contestar + * claro antes de gastar una llamada. Por eso tampoco deja migración. + */ + +/** Cuántas filas llegan al agente, como mucho. */ +export const MAX_FILAS = 50; +/** Y cuánto texto: una fila puede traer un JSON enorme. */ +const MAX_CARACTERES = 8000; + +export const verBaseTool: Tool = { + spec: { + name: "ver_base", + description: + "Mira la base de datos de la sala, sin cambiar nada. Sin `consulta`, devuelve la " + + "estructura: tablas, columnas, llaves, si tienen RLS y sus políticas. Úsalo ANTES de " + + "crear o cambiar tablas y antes de escribir código que las use, en vez de adivinar " + + `columnas. Con \`consulta\` (un SELECT), devuelve hasta ${MAX_FILAS} filas: sirve para ` + + "comprobar que algo se guardó o contar registros. No pasa por la app: lo que ves aquí " + + "puede no ser lo que la app ve, porque la app pasa por las políticas de RLS.", + input_schema: { + type: "object", + properties: { + consulta: { + type: "string", + description: "Opcional. Un SELECT (o WITH … SELECT) para ver filas. Sin esto, la estructura.", + }, + }, + }, + }, + async run(input, ctx) { + if (!ctx.leerBase) { + throw new ToolError( + "esta sala no tiene base de datos conectada. Se conecta desde el panel de Variables, " + + "con el botón de Supabase. Dilo en el chat y sigue con lo que sí puedas hacer.", + ); + } + const consulta = typeof input.consulta === "string" ? input.consulta.trim() : ""; + + if (!consulta) { + return describirEsquema(await leer(ctx.leerBase, CONSULTA_ESQUEMA)); + } + + if (!esDeLectura(consulta)) { + throw new ToolError( + "ver_base solo lee: la consulta tiene que empezar con SELECT, WITH, EXPLAIN, SHOW, " + + "TABLE o VALUES. Para cambiar la estructura usa la tool sql; las filas de la app " + + "las escribe el código de la app.", + ); + } + return describirFilas(await leer(ctx.leerBase, conTope(consulta))); + }, +}; + +async function leer(leerBase: (sql: string) => Promise, sql: string): Promise { + try { + return await leerBase(sql); + } catch (err) { + // Lo que diga Postgres le sirve al agente para corregir la consulta. Como + // `error inesperado` parecía una falla de Multi, no de su SQL. + const msg = err instanceof Error ? err.message : String(err); + throw new ToolError(`la base respondió con error, no se leyó nada: ${msg}`); + } +} + +/** Quita comentarios y espacios del principio, que no cambian qué es la consulta. */ +function sinComentariosAlInicio(sql: string): string { + let s = sql.trimStart(); + for (;;) { + if (s.startsWith("--")) s = s.slice(s.indexOf("\n") + 1 || s.length).trimStart(); + else if (s.startsWith("/*")) s = s.slice((s.indexOf("*/") + 2) || s.length).trimStart(); + else return s; + } +} + +export function esDeLectura(sql: string): boolean { + return /^(select|with|explain|show|table|values)\b/i.test(sinComentariosAlInicio(sql)); +} + +/** + * Le pone tope a una consulta de un solo SELECT, para que una tabla grande no + * viaje entera solo para recortarla aquí. Se pide una fila de más para saber si + * había más. Lo que no se puede envolver (un EXPLAIN, varias sentencias) va tal + * cual y se recorta al llegar. + */ +export function conTope(sql: string): string { + const limpia = sinComentariosAlInicio(sql).replace(/;\s*$/, ""); + if (limpia.includes(";") || !/^(select|with)\b/i.test(limpia)) return sql; + return `select * from (\n${limpia}\n) as consulta limit ${MAX_FILAS + 1}`; +} + +export function describirFilas(respuesta: unknown): string { + const filas = Array.isArray(respuesta) ? respuesta : []; + if (filas.length === 0) return "0 filas."; + const vistas = filas.slice(0, MAX_FILAS); + let texto = vistas.map((f) => JSON.stringify(f)).join("\n"); + if (texto.length > MAX_CARACTERES) texto = texto.slice(0, MAX_CARACTERES) + "\n… (recortado)"; + const cuantas = + filas.length > MAX_FILAS + ? `más de ${MAX_FILAS} filas; aquí van las primeras ${MAX_FILAS}. Filtra o agrega para ver el resto.` + : `${filas.length} ${filas.length === 1 ? "fila" : "filas"}.`; + return `${cuantas}\n${texto}`; +} + +interface Columna { + nombre: string; + tipo: string; + nulo: boolean; + default: string | null; +} +interface Politica { + nombre: string; + para: string; + roles: string[] | string | null; + using: string | null; + check: string | null; +} +interface TablaDeEsquema { + tabla: string; + rls: boolean; + rls_forzado: boolean; + columnas: Columna[]; + llaves: string[]; + politicas: Politica[]; +} + +/** La estructura, en el texto más corto que el agente pueda usar sin adivinar. */ +export function describirEsquema(respuesta: unknown): string { + const tablas = (Array.isArray(respuesta) ? respuesta : []) as TablaDeEsquema[]; + if (tablas.length === 0) return "La base no tiene tablas todavía (esquema public vacío)."; + + const bloques = tablas.map((t) => { + const rls = t.rls_forzado ? "RLS forzado" : t.rls ? "RLS activo" : "SIN RLS"; + const lineas = [`${t.tabla} (${rls})`]; + for (const c of t.columnas ?? []) { + const partes = [` ${c.nombre} ${c.tipo}`]; + if (!c.nulo) partes.push("not null"); + if (c.default) partes.push(`default ${c.default}`); + lineas.push(partes.join(" ")); + } + for (const k of t.llaves ?? []) lineas.push(` ${k}`); + const politicas = t.politicas ?? []; + if (politicas.length === 0 && t.rls) { + lineas.push(" sin políticas: con RLS así, la app no puede leer ni escribir nada"); + } + for (const p of politicas) { + const roles = Array.isArray(p.roles) ? p.roles.join(",") : (p.roles ?? "").replace(/[{}]/g, ""); + let linea = ` política "${p.nombre}" ${p.para} to ${roles || "public"}`; + if (p.using) linea += ` using (${p.using})`; + if (p.check) linea += ` with check (${p.check})`; + lineas.push(linea); + } + return lineas.join("\n"); + }); + + let texto = `${tablas.length} ${tablas.length === 1 ? "tabla" : "tablas"} en public:\n\n${bloques.join("\n\n")}`; + if (texto.length > MAX_CARACTERES * 2) texto = texto.slice(0, MAX_CARACTERES * 2) + "\n… (recortado)"; + return texto; +} diff --git a/server/src/demos/actividad.ts b/server/src/demos/actividad.ts index ee6b7fc..6f003b4 100644 --- a/server/src/demos/actividad.ts +++ b/server/src/demos/actividad.ts @@ -59,6 +59,7 @@ check("leer ShareDialog.tsx", leer.tipo === "leer" && leer.archivo === "ShareDia check("y el detalle guarda la ruta completa", leer.detalle === "src/components/ShareDialog.tsx"); const sql = accionDeTool("sql", { sql: "create table x ()", descripcion: "crea-tabla-x" }); check("sql es cambiar la base", sql.tipo === "baseDeDatos"); +check("ver_base es mirarla", accionDeTool("ver_base", {}).tipo === "verBase"); check("una tool desconocida no se pierde", accionDeTool("nueva_tool", {}).detalle === "nueva_tool"); console.log(`\n${pass} pasaron, ${fail} fallaron\n`); diff --git a/server/src/demos/ver-base.ts b/server/src/demos/ver-base.ts new file mode 100644 index 0000000..3d75419 --- /dev/null +++ b/server/src/demos/ver-base.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verBaseTool, conTope, esDeLectura, MAX_FILAS } from "../agent/tools/ver-base.js"; +import { sqlTool } from "../agent/tools/sql.js"; +import { ToolError, type ToolContext } from "../agent/tools/base.js"; +import { CONSULTA_ESQUEMA } from "../supabase.js"; + +/** + * Demo: el agente puede ver la base de la sala, sin cambiarla. + * Uso: npm run demo:ver-base + * + * Antes la única tool de la base era `sql`, que contesta "listo" y nada más: el + * agente adivinaba tablas y columnas. Esto revisa que `ver_base` describa la + * estructura, traiga filas con tope, y no deje pasar nada que escriba. + * + * No necesita red ni base: la consulta se "corre" contra una función que anota + * lo que recibió y devuelve lo que devolvería Supabase. + */ + +let pass = 0; +let fail = 0; +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + pass++; + console.log(` [ok] ${name}`); + } else { + fail++; + console.log(` [X] ${name} ${detail}`); + } +} + +/** Lo que devuelve CONSULTA_ESQUEMA en una base como la de una sala real. */ +const ESQUEMA = [ + { + tabla: "tareas", + rls: true, + rls_forzado: true, + columnas: [ + { nombre: "id", tipo: "uuid", nulo: false, default: "gen_random_uuid()" }, + { nombre: "user_id", tipo: "uuid", nulo: false, default: "auth.uid()" }, + { nombre: "titulo", tipo: "text", nulo: false, default: null }, + { nombre: "hecha", tipo: "boolean", nulo: true, default: "false" }, + ], + llaves: ["PRIMARY KEY (id)", "FOREIGN KEY (user_id) REFERENCES auth.users(id)"], + politicas: [ + { nombre: "mis_tareas", para: "ALL", roles: "{authenticated}", using: "(auth.uid() = user_id)", check: "(auth.uid() = user_id)" }, + ], + }, + { + tabla: "notas", + rls: true, + rls_forzado: false, + columnas: [{ nombre: "id", tipo: "bigint", nulo: false, default: null }], + llaves: [], + politicas: [], + }, +]; + +async function main() { + console.log("\n=== ver la base ===\n"); + + const dir = await mkdtemp(join(tmpdir(), "multi-ver-base-")); + const pedidas: string[] = []; + let respuesta: unknown = []; + const ctx = { + workspaceDir: dir, + leerBase: async (sql: string) => { + pedidas.push(sql); + return respuesta; + }, + } as unknown as ToolContext; + const correr = async (input: Record, c: ToolContext = ctx): Promise => { + try { + return String(await verBaseTool.run(input, c)); + } catch (err) { + return err instanceof ToolError ? `ERROR: ${err.message}` : `OTRO ERROR: ${err}`; + } + }; + + console.log("1. Sin consulta, la estructura"); + respuesta = ESQUEMA; + const esquema = await correr({}); + check("pide la consulta del catálogo", pedidas.at(-1) === CONSULTA_ESQUEMA); + check("cuenta las tablas", esquema.startsWith("2 tablas en public"), esquema.split("\n")[0]); + check("dice si el RLS está forzado", esquema.includes("tareas (RLS forzado)")); + check("columnas con tipo, not null y default", esquema.includes("user_id uuid not null default auth.uid()")); + check("las llaves", esquema.includes("FOREIGN KEY (user_id) REFERENCES auth.users(id)")); + check( + "las políticas, con sus roles", + esquema.includes('política "mis_tareas" ALL to authenticated using ((auth.uid() = user_id))'), + esquema, + ); + check("avisa de una tabla con RLS y sin políticas", esquema.includes("sin políticas")); + respuesta = []; + check("una base vacía lo dice", (await correr({})).includes("no tiene tablas")); + + console.log("\n2. Con consulta, las filas, con tope"); + respuesta = Array.from({ length: MAX_FILAS + 1 }, (_, i) => ({ id: i, titulo: `tarea ${i}` })); + const filas = await correr({ consulta: "select * from tareas;" }); + check( + "la consulta viaja envuelta con limit", + pedidas.at(-1) === `select * from (\nselect * from tareas\n) as consulta limit ${MAX_FILAS + 1}`, + pedidas.at(-1), + ); + check("avisa que hay más", filas.startsWith(`más de ${MAX_FILAS} filas`), filas.split("\n")[0]); + check(`y trae solo ${MAX_FILAS}`, filas.split("\n").length === MAX_FILAS + 1); + respuesta = [{ total: 3 }]; + check("una sola fila", (await correr({ consulta: "select count(*) as total from tareas" })) === '1 fila.\n{"total":3}'); + respuesta = []; + check("cero filas", (await correr({ consulta: "select * from tareas where false" })) === "0 filas."); + check( + "un WITH también se envuelve", + conTope("with x as (select 1) select * from x").endsWith(`limit ${MAX_FILAS + 1}`), + ); + check("un EXPLAIN va tal cual", conTope("explain select 1") === "explain select 1"); + check("varias sentencias van tal cual", conTope("select 1; select 2") === "select 1; select 2"); + check("los comentarios del principio no confunden", esDeLectura("-- cuántas\n/* hay */ select 1")); + + console.log("\n3. Lo que escribe no pasa"); + const antes = pedidas.length; + for (const q of ["delete from tareas", "update tareas set hecha = true", "drop table notas", "insert into notas values (1)"]) { + const r = await correr({ consulta: q }); + check(`rechaza: ${q}`, r.startsWith("ERROR: ver_base solo lee"), r); + } + check("sin llamar a la base", pedidas.length === antes); + check("ni dejar migración", !existsSync(join(dir, "migraciones"))); + + console.log("\n4. Errores y sala sin base"); + const sinBase = await correr({}, { workspaceDir: dir } as unknown as ToolContext); + check("sin base, dice cómo conectarla", sinBase.includes("panel de Variables"), sinBase); + const conFallo = { + workspaceDir: dir, + leerBase: async () => { + throw new Error('respondió 400: {"message":"ERROR: 42703: column \\"nombre\\" does not exist"}'); + }, + } as unknown as ToolContext; + const fallo = await correr({ consulta: "select nombre from tareas" }, conFallo); + check("el error de Postgres llega limpio", fallo.startsWith("ERROR: la base respondió con error") && fallo.includes("42703"), fallo); + + console.log("\n5. Y sql ya no dice 'error inesperado'"); + const sqlConFallo = { + workspaceDir: dir, + ejecutarSql: async () => { + throw new Error('respondió 400: {"message":"ERROR: 42P07: relation \\"tareas\\" already exists"}'); + }, + } as unknown as ToolContext; + let errSql = ""; + try { + await sqlTool.run({ sql: "create table tareas (id bigint)", descripcion: "crea-tareas" }, sqlConFallo); + } catch (err) { + errSql = err instanceof ToolError ? err.message : `OTRO ERROR: ${err}`; + } + check("el error es de la tool, con lo que dijo Postgres", errSql.includes("42P07"), errSql); + check("y le dice que revise con ver_base", errSql.includes("ver_base")); + check("sin migración de algo que falló", !existsSync(join(dir, "migraciones"))); + + await rm(dir, { recursive: true, force: true }); + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("\ndemo falló:", err); + process.exit(1); +}); diff --git a/server/src/engine/actividad.ts b/server/src/engine/actividad.ts index 347309e..030bdb6 100644 --- a/server/src/engine/actividad.ts +++ b/server/src/engine/actividad.ts @@ -18,6 +18,7 @@ export type TipoDeAccion = | "buscarArchivos" | "buscarTexto" | "baseDeDatos" + | "verBase" | "adjunto" | "crearProyecto" | "instalar" @@ -53,6 +54,8 @@ export function accionDeTool(nombre: string, input: Record): Ac return { tipo: "buscarTexto", detalle: texto("pattern") }; case "sql": return { tipo: "baseDeDatos", detalle: texto("descripcion") }; + case "ver_base": + return { tipo: "verBase", detalle: texto("consulta") || undefined }; case "usar_adjunto": return { tipo: "adjunto", detalle: texto("destino") || undefined }; case "bash": diff --git a/server/src/index.ts b/server/src/index.ts index 3f76bd0..7f860a4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1196,7 +1196,22 @@ async function repararVariablesSupabase(roomId: string, workspaceDir: string): P * credencial nunca entra al contenedor, que es la misma regla que ya cumple la * API key del modelo. */ -async function sqlDeLaSala(roomId: string): Promise<((sql: string) => Promise) | undefined> { +async function sqlDeLaSala(roomId: string): Promise<((sql: string) => Promise) | undefined> { + return conLaBaseDeLaSala(roomId, {}); +} + +/** + * Con qué LEE la base el agente de esta sala (tool ver_base), o undefined si no + * tiene. Igual que `sqlDeLaSala`, pero cada consulta va en solo lectura. + */ +async function lecturaDeLaSala(roomId: string): Promise<((sql: string) => Promise) | undefined> { + return conLaBaseDeLaSala(roomId, { soloLectura: true }); +} + +async function conLaBaseDeLaSala( + roomId: string, + opciones: { soloLectura?: boolean }, +): Promise<((sql: string) => Promise) | undefined> { const conexion = await (await getStorage()).conexionSupabase(roomId); if (!conexion?.proyecto) return undefined; const ref = conexion.proyecto; @@ -1206,7 +1221,7 @@ async function sqlDeLaSala(roomId: string): Promise<((sql: string) => Promise { return anon.api_key; } -/** Corre SQL en el proyecto. */ -export async function ejecutarSql(acceso: string, ref: string, query: string): Promise { - await pedir(acceso, `/v1/projects/${ref}/database/query`, { +/** + * Corre SQL en el proyecto y devuelve lo que respondió: las filas de la última + * sentencia, o una lista vacía si no regresa ninguna. + * + * Con `soloLectura`, Supabase corre la consulta en una transacción de solo + * lectura: un `delete` escondido en un CTE falla del lado de Postgres. Esa es + * la garantía de verdad; lo que revise quien llama es solo para avisar antes. + */ +export async function ejecutarSql( + acceso: string, + ref: string, + query: string, + opciones: { soloLectura?: boolean } = {}, +): Promise { + return pedir(acceso, `/v1/projects/${ref}/database/query`, { method: "POST", - body: JSON.stringify({ query }), + body: JSON.stringify(opciones.soloLectura ? { query, read_only: true } : { query }), }); } +/** + * La estructura de la base, en una sola consulta: una fila por tabla de + * `public`, con sus columnas, llaves y políticas ya agrupadas. + * + * Todo va calificado con su esquema y sale del catálogo, no de las tablas: no + * lee ni una fila de datos, así que es barata aunque la base sea grande. + */ +export const CONSULTA_ESQUEMA = ` +select + c.relname as tabla, + c.relrowsecurity as rls, + c.relforcerowsecurity as rls_forzado, + coalesce(( + select json_agg(json_build_object( + 'nombre', a.attname, + 'tipo', pg_catalog.format_type(a.atttypid, a.atttypmod), + 'nulo', not a.attnotnull, + 'default', pg_catalog.pg_get_expr(d.adbin, d.adrelid) + ) order by a.attnum) + from pg_catalog.pg_attribute a + left join pg_catalog.pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum + where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped + ), '[]'::json) as columnas, + coalesce(( + select json_agg(pg_catalog.pg_get_constraintdef(k.oid) order by k.contype, k.conname) + from pg_catalog.pg_constraint k + where k.conrelid = c.oid and k.contype in ('p', 'f', 'u') + ), '[]'::json) as llaves, + coalesce(( + select json_agg(json_build_object( + 'nombre', p.policyname, 'para', p.cmd, 'roles', p.roles, + 'using', p.qual, 'check', p.with_check + ) order by p.policyname) + from pg_catalog.pg_policies p + where p.schemaname = 'public' and p.tablename = c.relname + ), '[]'::json) as politicas +from pg_catalog.pg_class c +join pg_catalog.pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'public' and c.relkind in ('r', 'p') +order by c.relname +`; + /** * Deja el proyecto de modo que TODA tabla nueva nazca con RLS activo. * diff --git a/web/src/App.tsx b/web/src/App.tsx index d8d534e..5df0145 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2294,6 +2294,7 @@ interface AccionAgente { | "buscarArchivos" | "buscarTexto" | "baseDeDatos" + | "verBase" | "adjunto" | "crearProyecto" | "instalar" @@ -2324,6 +2325,8 @@ function fraseDeAccion(a: AccionAgente, t: Textos): string { return t.actBuscarTexto; case "baseDeDatos": return t.actBaseDeDatos; + case "verBase": + return t.actVerBase; case "adjunto": return t.actAdjunto; case "crearProyecto": diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 6bb8a63..df04db6 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -299,6 +299,7 @@ const TEXTOS = { actBuscarArchivos: "Buscando archivos", actBuscarTexto: "Buscando en el código", actBaseDeDatos: "Cambiando la base de datos", + actVerBase: "Revisando la base de datos", actAdjunto: "Usando un archivo adjunto", actCrearProyecto: "Creando el proyecto", actInstalar: "Instalando dependencias", @@ -568,6 +569,7 @@ const TEXTOS = { actBuscarArchivos: "Looking for files", actBuscarTexto: "Searching the code", actBaseDeDatos: "Changing the database", + actVerBase: "Looking at the database", actAdjunto: "Using an attached file", actCrearProyecto: "Creating the project", actInstalar: "Installing dependencies",