From 4e5e5d1f26abc424c201dfc70043f87a1ade94d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:23:32 +0000 Subject: [PATCH] Reconectar Supabase adopta el proyecto de la sala en vez de crear otro Desconectar borra la conexion, no el proyecto (es de la persona). Al volver a conectar, Multi intentaba crear otro con el mismo nombre: Supabase lo rechaza y la sala se quedaba sin base; y si lo aceptara, la app apuntaria a una base vacia sin sus tablas. Ahora, sin proyecto guardado, se busca uno llamado multi- en la cuenta y se adopta (si esta pausado, se dice). La contrasena no se recupera: Supabase no la devuelve nunca, y la app no la necesita. Y el panel ya no dice "Conectar Supabase" unos segundos al volver de autorizar: la vuelta recarga la pagina y los primeros avisos por socket se perdian. El GET del panel ahora trae la etapa en curso. ROADMAP: la idea de Blender en la sala, con lo que hay que probar antes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJ5oT2Xm3VZMvbPAcgKbz4 --- ROADMAP.md | 32 ++++++++++++++++++++++++++++++ server/src/index.ts | 44 ++++++++++++++++++++++++++++++++++++++++-- server/src/supabase.ts | 5 +++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82f94f4..cd40b23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -464,3 +464,35 @@ al agente. Si con eso se coordinan mejor, la regla vale la pena. Ataca el hueco documentado en `docs-write-pisa.md`: el CAS protege archivos de escrituras simultaneas, pero no evita que dos agentes trabajen sobre premisas incompatibles. + +--- + +## Blender en la sala, en vivo en el preview + +Idea del 23 de septiembre de 2026, sin construir todavía. La pidió un usuario +que llegó a hacer una tarea de Blender, no pudo, y se fue a probar otra cosa. + +**Lo que no encaja: un MCP de Blender.** Los que existen (`blender-mcp`) le +mandan Python a un Blender abierto en la compu de quien lo usa, por un addon +que escucha en `localhost`. El agente de Multi corre en el server y no llega a +ese `localhost` sin un programa puente del lado del usuario; solo él podría +"hospedar" el Blender, y el agente tampoco habla MCP. + +**Lo que sí encaja: Blender con ventana dentro de la sala, transmitido.** +Blender corre en el contenedor sobre una pantalla virtual (`Xvfb`) y se ve en el +navegador con noVNC. El preview ES esa ventana: toda la sala la ve y la puede +girar. El agente lo maneja mandándole Python, que es lo mismo que hace el MCP +por dentro, con un script por bash. noVNC puede ser el "dev server" de la sala +(el único puerto que sale), y el proxy del preview ya pasa websockets. + +**Lo que falta averiguar antes de prometerlo:** +1. Rendimiento: sin GPU, Blender dibuja por CPU; con 2 CPU y 2 GB puede ir lento. +2. `Xvfb` se instala con `apt` y el agente no es root en la sala. Probar primero + que lo instale él sin root (desempacar los `.deb` en su HOME, o micromamba); + si sale frágil, agregar solo `Xvfb` a `docker/room.Dockerfile`. Blender mismo + (~300 MB, portátil) lo baja el agente solo cuando alguien lo pide. +3. Que noVNC funcione a través del proxy del preview. + +**Plan B que seguro funciona:** Blender sin ventana (`blender --background +--python`), renders a PNG y un visor 3D (three.js) del `.glb` exportado en el +preview, con botón para descargar el `.blend`. No se modela a mano, se pide. diff --git a/server/src/index.ts b/server/src/index.ts index 3362862..f55baac 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -97,6 +97,7 @@ import { nuevaAutorizacion, nuevaPassword, organizaciones, + proyectos, ejecutarSql, refrescar as refrescarSupabase, urlDeAutorizacion as urlDeAutorizacionSupabase, @@ -797,6 +798,7 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/supabase", async (req) => { configurado: credencialDeSupabase() !== null && hayLlave(), proyecto: conexion?.proyecto ?? null, url: conexion?.proyecto ? urlDelProyecto(conexion.proyecto) : null, + etapa: etapaSupabase.get(req.params.id) ?? null, // Tener proyecto no es lo mismo que tener las variables en el `.env`, que // es lo único que el agente ve. El panel decía "conectada" en cuanto el // proyecto existía, y si la preparación se cortaba después (el tope de @@ -913,13 +915,28 @@ fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( */ const preparacionesDeSupabase = new KeyedMutex(); +/** + * Por dónde va la preparación de cada sala, para el `GET` del panel. + * + * Las etapas viajan por socket, y la vuelta de autorizar RECARGA la página: los + * primeros avisos llegaban antes de que la Sala se conectara, y el panel decía + * "Conectar Supabase" unos segundos como si no hubiera pasado nada. Con esto el + * panel sabe desde que carga que la base se está preparando. + */ +const etapaSupabase = new Map(); + async function prepararProyecto(roomId: string): Promise { // Serializado por sala: dos caminos llaman aquí (el callback al autorizar, y // el botón de conectar cuando retoma), y si coinciden los dos leen que no hay // proyecto y los dos lo crean. Supabase rechaza el segundo por nombre // repetido, así que el síntoma es un error confuso en vez de una carrera // evidente. Pasó la primera vez que se reconectó. - return preparacionesDeSupabase.run(roomId, () => prepararProyectoSerializado(roomId)); + etapaSupabase.set(roomId, "creando"); + try { + return await preparacionesDeSupabase.run(roomId, () => prepararProyectoSerializado(roomId)); + } finally { + etapaSupabase.delete(roomId); + } } async function prepararProyectoSerializado(roomId: string): Promise { @@ -935,8 +952,10 @@ async function prepararProyectoSerializado(roomId: string): Promise { const room = getRoom(roomId) ?? (await wakeRoom(roomId)); if (!room) return; - const avisar = (etapa: string, extra: Record = {}) => + const avisar = (etapa: string, extra: Record = {}) => { + etapaSupabase.set(roomId, etapa); io.to(roomId).emit("supabase:etapa", { etapa, ...extra }); + }; // Si el proyecto YA existe se retoma desde donde se quedó, en vez de crear // otro. Levantar una base tarda minutos y cualquier cosa puede cortar el @@ -945,6 +964,27 @@ async function prepararProyectoSerializado(roomId: string): Promise { let ref = conexion.proyecto ?? null; let password = conexion.password ?? null; + // Sin proyecto guardado, puede que igual exista: la sala se desconectó (eso + // borra la conexión, no el proyecto, que es de la persona) y ahora vuelve a + // conectar. Crear otro rompía la sala: Supabase rechaza el nombre repetido, + // y si no lo rechazara la app quedaría apuntando a una base vacía, sin sus + // tablas ni sus datos. Se adopta el que ya lleva el nombre de esta sala. + if (!ref) { + const existente = (await proyectos(acceso)).find((p) => p.name === `multi-${roomId}`); + if (existente) { + if (existente.status === "INACTIVE") { + throw new FalloDeSupabase( + "el proyecto de esta sala está pausado en Supabase: reactívalo desde su panel y vuelve a conectar", + ); + } + ref = existente.ref ?? existente.id; + console.log(`[supabase] ${roomId} ya tenía el proyecto ${ref}: se adopta en vez de crear otro`); + // La contraseña no se recupera: Supabase no la devuelve nunca. La app no + // la necesita; solo quien quiera entrar a la base por fuera. + await (await getStorage()).guardarConexionSupabase({ ...conexion, proyecto: ref, password: null }); + } + } + if (!ref) { avisar("creando"); const orgs = await organizaciones(acceso); diff --git a/server/src/supabase.ts b/server/src/supabase.ts index cf403d9..b855141 100644 --- a/server/src/supabase.ts +++ b/server/src/supabase.ts @@ -253,6 +253,11 @@ export interface Proyecto { region?: string; } +/** Los proyectos que la cuenta autorizada puede ver, de todas sus organizaciones. */ +export async function proyectos(acceso: string): Promise { + return pedir(acceso, "/v1/projects"); +} + /** * Crea un proyecto y devuelve su referencia. *