Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ jobs:
- run: npm run demo:internet
- run: npm run demo:publicar
- run: npm run demo:proxy-rutas
- run: npm run demo:remix
- run: npm run demo:documento
- run: npm run demo:doc-sync
- run: npm run demo:comentarios
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"demo:agentes-se-ven": "npm run demo:agentes-se-ven -w server",
"demo:imagenes": "npm run demo:imagenes -w server",
"demo:proxy-rutas": "npm run demo:proxy-rutas -w server",
"demo:remix": "npm run demo:remix -w server",
"demo:providers": "npm run demo:providers -w server",
"dev:test": "npm run dev:test -w server",
"demo:presupuesto": "npm run demo:presupuesto -w server",
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"demo:turno-cortado": "tsx src/demos/turno-cortado.ts",
"demo:imagenes": "tsx src/demos/imagenes.ts",
"demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts",
"demo:remix": "tsx src/demos/remix.ts",
"demo:exportar": "tsx src/demos/exportar.ts",
"uso": "tsx --no-warnings src/demos/uso.ts",
"demo:variables": "tsx src/demos/variables.ts",
Expand Down
116 changes: 116 additions & 0 deletions server/src/demos/remix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { existsSync } from "node:fs";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

/**
* Demo: el remix de una sala.
* Uso: npm run demo:remix
*
* Un remix es una sala nueva con una copia del proyecto, ligada al original.
* Lo que se revisa es lo que importa de una copia: que traiga el código, que NO
* traiga los secretos (cualquiera con el link puede hacer un remix), que quede
* ligada para "Volver al original", y que cambiarla no toque el original.
*
* Sin Docker ni red: salas de verdad, con una base y workspaces temporales.
*/

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}`);
}
}

async function main() {
console.log("=== Remix de una sala ===\n");
const raiz = await mkdtemp(join(tmpdir(), "multi-remix-"));
// Antes de importar: la base se abre con lo que diga MULTI_DB, y sin
// contenedor, para que la sala no intente levantar uno.
process.env.MULTI_DB = join(raiz, "multi.db");
process.env.MULTI_SIN_AISLAMIENTO = "1";
const { createRoom, createRemix, NadaQueRemixear } = await import("../rooms.js");
const { commitAll } = await import("../engine/git.js");
const { getStorage } = await import("../storage/index.js");
const storage = await getStorage();
const creadas: string[] = [];

try {
console.log("1. El remix trae el código guardado");
const original = await createRoom();
creadas.push(original.workspace.dir);
const dir = original.workspace.dir;
await mkdir(join(dir, "src"), { recursive: true });
await mkdir(join(dir, "node_modules", "algo"), { recursive: true });
await writeFile(join(dir, "index.html"), "<h1>Box</h1>\n");
await writeFile(join(dir, "src", "juego.js"), "export const golpe = 10;\n");
await writeFile(join(dir, ".env"), "SECRETO=no-se-copia\n");
await writeFile(join(dir, "node_modules", "algo", "index.js"), "// pesado\n");
await writeFile(join(dir, "datos.db"), "binario de prueba");
await commitAll(dir, { message: "el juego", author: "agente-1" });
// Algo escrito después del último guardado no entra: el remix es de lo guardado.
await writeFile(join(dir, "sin-guardar.txt"), "a medias\n");

const remix = await createRemix(original);
creadas.push(remix.workspace.dir);
const rdir = remix.workspace.dir;
check("es otra sala, con otro workspace", remix.id !== original.id && rdir !== dir);
check("trae los archivos del proyecto", existsSync(join(rdir, "index.html")) && existsSync(join(rdir, "src", "juego.js")));
check("con el mismo contenido", (await readFile(join(rdir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n");

console.log("\n2. Lo que no se copia");
check("no trae el .env (los secretos no se regalan)", !existsSync(join(rdir, ".env")));
check("ni node_modules", !existsSync(join(rdir, "node_modules")));
check("ni la base del proyecto", !existsSync(join(rdir, "datos.db")));
check("ni lo que no estaba guardado", !existsSync(join(rdir, "sin-guardar.txt")));

console.log("\n3. Queda ligado a su original");
check("sabe de qué es remix", remix.origenId === original.id);
check("y se llama como el original con (remix)", remix.nombre === `${original.id} (remix)`, String(remix.nombre));
const guardada = await storage.getRoom(remix.id);
check("el vínculo se guarda en la base", guardada?.origenId === original.id, JSON.stringify(guardada));
check("y el nombre también", guardada?.nombre === `${original.id} (remix)`);
const otro = await createRemix(original);
creadas.push(otro.workspace.dir);
check("el segundo se numera para distinguirlo", otro.nombre === `${original.id} (remix 2)`, String(otro.nombre));
const lista = await storage.remixesDe(original.id);
check("el original lista sus remixes", lista.length === 2 && lista.some((r) => r.id === remix.id) && lista.some((r) => r.id === otro.id), JSON.stringify(lista));
check("una sala normal no es remix de nada", (await storage.getRoom(original.id))?.origenId == null);

console.log("\n4. Cambiar el remix no toca el original");
await writeFile(join(rdir, "src", "juego.js"), "export const golpe = 99;\n");
await commitAll(rdir, { message: "más fuerte", author: "agente-1" });
check("el original sigue igual", (await readFile(join(dir, "src", "juego.js"), "utf8")) === "export const golpe = 10;\n");
const { stdout } = await import("node:child_process").then(({ execFileSync }) => ({
stdout: execFileSync("git", ["log", "--format=%s"], { cwd: rdir, encoding: "utf8" }),
}));
check("el remix tiene su propio historial", stdout.trim().split("\n").join("|") === `más fuerte|Remix de ${original.id}`, stdout);

console.log("\n5. Una sala sin nada guardado no se puede remixear");
const vacia = await createRoom();
creadas.push(vacia.workspace.dir);
let error = "";
try {
await createRemix(vacia);
} catch (err) {
error = err instanceof NadaQueRemixear ? err.message : `otro error: ${String(err)}`;
}
check("lo dice claro", error.includes("no tiene nada guardado"), error);
} finally {
for (const d of creadas) await rm(d, { recursive: true, force: true });
await rm(raiz, { recursive: true, force: true });
}

console.log(`\n${pass} pasaron, ${fail} fallaron`);
process.exit(fail > 0 ? 1 : 0);
}

main().catch((err) => {
console.error("demo falló:", err);
process.exit(1);
});
42 changes: 42 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { existsSync } from "node:fs";

import {
createRoom,
createRemix,
NadaQueRemixear,
getRoom,
allRooms,
addMember,
Expand Down Expand Up @@ -533,6 +535,32 @@ fastify.post("/rooms", async () => {
return { id: room.id };
});

/**
* Hacer un remix: una copia del proyecto en una sala nueva, ligada a esta.
*
* Responde el id y la Sala navega ahí. A la sala original se le avisa, para que
* su lista de remixes se actualice sin recargar, y queda una línea en su chat.
* Bajo /rooms, como el export, para que el proxy del preview no se lo quede.
*/
fastify.post<{ Params: { id: string }; Body: { autor?: unknown } | undefined }>(
"/rooms/:id/remix",
async (req, reply) => {
const origen = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
if (!origen) return reply.code(404).send({ error: "sala no encontrada" });
try {
const remix = await createRemix(origen);
const autor = typeof req.body?.autor === "string" ? req.body.autor.trim().slice(0, 40) : "";
io.to(origen.id).emit("room:remixes", { remixes: await (await getStorage()).remixesDe(origen.id) });
systemMsg(origen, autor ? `${autor} hizo un remix de este proyecto` : "alguien hizo un remix de este proyecto");
return { id: remix.id };
} catch (err) {
if (err instanceof NadaQueRemixear) return reply.code(409).send({ error: err.message });
console.error(`[sala ${origen.id}] no se pudo hacer el remix:`, err);
return reply.code(500).send({ error: "no se pudo hacer el remix" });
}
},
);

// La línea de tiempo de una sala (alimenta el scrubber).
fastify.get<{ Params: { id: string } }>("/rooms/:id/history", async (req, reply) => {
const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id));
Expand Down Expand Up @@ -1746,6 +1774,10 @@ io.on("connection", (socket) => {
// Dónde está publicada, si lo está: es un dato de la sala, no una noticia
// de hace rato. Quien entra mañana tiene que poder abrirla igual.
urlPublicada: room.urlPublicada ?? null,
// De qué sala es remix, y los remixes de esta: para "Volver al original"
// y la lista para brincar entre versiones.
origen: await origenDe(room),
remixes: await (await getStorage()).remixesDe(room.id),
nombrePublicado: room.nombrePublicado ?? null,
agents: room.agents.list(),
actividad: Object.fromEntries(ultimaAccion.get(room.id) ?? []),
Expand Down Expand Up @@ -2588,6 +2620,16 @@ async function say(
}
}

/** La sala de la que esta es remix, con su nombre, o null. */
async function origenDe(room: Room): Promise<{ id: string; nombre: string | null } | null> {
if (!room.origenId) return null;
const vivo = getRoom(room.origenId);
if (vivo) return { id: vivo.id, nombre: vivo.nombre ?? null };
const guardado = await (await getStorage()).getRoom(room.origenId);
// El original pudo haberse borrado: entonces el remix ya no tiene a dónde volver.
return guardado ? { id: guardado.id, nombre: guardado.nombre ?? null } : null;
}

function systemMsg(room: Room, text: string, color = "#a9abd0"): void {
void say(room, { from: "sistema", color, role: "system", text });
}
Expand Down
70 changes: 70 additions & 0 deletions server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { readFile, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace, homeDir, type Workspace } from "./engine/workspace.js";
import { extractTo, commitAll, hayCommits } from "./engine/git.js";
import { startPreview, detectLaunch, type Preview } from "./engine/preview.js";
import {
isDockerAvailable,
Expand Down Expand Up @@ -93,6 +94,8 @@ export interface Room {
* elegir (esa se llama como su id).
*/
nombrePublicado?: string | null;
/** La sala de la que esta es remix (ver createRemix). Null si no lo es. */
origenId?: string | null;
/**
* Si en esta sala hay que mencionar al agente para despertarlo.
*
Expand Down Expand Up @@ -285,6 +288,7 @@ async function despertarSala(id: string): Promise<Room | null> {
nombre: stored.nombre ?? null,
urlPublicada: stored.urlPublicada ?? null,
nombrePublicado: stored.nombrePublicado ?? null,
origenId: stored.origenId ?? null,
// Las salas anteriores a esta columna vuelven en multi, que es como se
// comportaron siempre: nada cambia debajo de quien ya las usaba.
modo: stored.modo ?? "multi",
Expand Down Expand Up @@ -874,6 +878,72 @@ export async function renameRoom(room: Room, crudo: unknown): Promise<string | n
return nombre;
}

/** La sala no tiene nada guardado que copiar todavía. */
export class NadaQueRemixear extends Error {}

/**
* Un remix: una sala nueva con una copia del proyecto de otra, ligada a ella.
*
* Es una sala aparte y no una rama dentro de la misma por lo multijugador: si
* alguien cambiara de rama, la vista previa y los archivos cambiarían para
* todos los que están adentro. Así el original sigue corriendo intacto mientras
* alguien experimenta, y cada quien está en su versión al mismo tiempo. En la
* Sala se ve como una versión: "Remix de…" y "Volver al original".
*
* Se copia lo último guardado (`git archive HEAD`, lo mismo que el .zip), no el
* directorio. Eso deja fuera el .env, node_modules, dist y la base *.db sin un
* filtro aparte que se pueda equivocar: cualquiera con el link puede hacer un
* remix, y copiar los secretos sería regalarlos. Tampoco viajan el chat, la
* conexión de Supabase ni lo publicado: el remix empieza limpio.
*/
export async function createRemix(origen: Room): Promise<Room> {
if (!(await hayCommits(origen.workspace.dir))) {
throw new NadaQueRemixear("esta sala todavía no tiene nada guardado que copiar");
}
const storage = await getStorage();
let id = genId();
while (rooms.has(id) || (await storage.getRoom(id))) id = genId();

const workspace = await createWorkspace(id, { clean: true });
await extractTo(origen.workspace.dir, "HEAD", workspace.dir);
const deQuien = origen.nombre ?? origen.id;
await commitAll(workspace.dir, { message: `Remix de ${deQuien}`, author: "Multti" });

// Numerados para que se distingan en la lista: "(remix)", "(remix 2)"…
const cuantos = (await storage.remixesDe(origen.id)).length;
const sufijo = cuantos === 0 ? " (remix)" : ` (remix ${cuantos + 1})`;

const room: Room = {
id,
nombre: `${deQuien.slice(0, MAX_NOMBRE_SALA - sufijo.length)}${sufijo}`,
origenId: origen.id,
modo: "solo",
workspace,
preview: null,
members: new Map(),
histories: new Map(),
selections: new Map(),
agents: new AgentRegistry(),
coordinator: new RunCoordinator(),
vaciaDesde: Date.now(),
};
rooms.set(id, room);

const now = Date.now();
await storage.createRoom({
id,
workspaceDir: workspace.dir,
createdAt: now,
lastActiveAt: now,
modo: room.modo,
origenId: origen.id,
});
await storage.renameRoom(id, room.nombre ?? null);

void bootPreview(room);
return room;
}

/**
* Cambia si en esta sala hay que mencionar al agente.
*
Expand Down
28 changes: 25 additions & 3 deletions server/src/storage/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ConexionSupabase,
ModoDeSala,
SalaDeUsuario,
RemixDeSala,
Storage,
StoredMessage,
StoredRoom,
Expand Down Expand Up @@ -47,6 +48,8 @@ export class SqliteStorage implements Storage {
-- Con qué nombre se publicó (nombre.pages.dev). Null en las salas que
-- publicaron antes de esta columna: su proyecto se llama como su id.
nombre_publicado TEXT,
-- La sala de la que es remix. Null en las salas normales.
origen_id TEXT,
-- Si hay que mencionar al agente. Null es "multi": las salas de antes
-- de esta columna siguen comportándose igual que siempre.
modo TEXT
Expand Down Expand Up @@ -153,6 +156,13 @@ export class SqliteStorage implements Storage {
// ya la tiene
}

// De qué sala es remix, para el "Volver al original" y la lista de remixes.
try {
this.db.exec(`ALTER TABLE rooms ADD COLUMN origen_id TEXT`);
} catch {
// ya la tiene
}

// El nombre que la gente le pone a la sala. Nullable a propósito: sin él la
// interfaz cae al id, que es lo que se ha visto siempre.
try {
Expand Down Expand Up @@ -228,10 +238,21 @@ export class SqliteStorage implements Storage {
async createRoom(room: StoredRoom): Promise<void> {
this.db
.prepare(
`INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo)
VALUES (?, ?, ?, ?, ?)`,
`INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo, origen_id)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null);
.run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null, room.origenId ?? null);
}

async remixesDe(id: string): Promise<RemixDeSala[]> {
const filas = this.db
.prepare(`SELECT id, nombre, created_at FROM rooms WHERE origen_id = ? ORDER BY created_at DESC`)
.all(id) as Record<string, unknown>[];
return filas.map((r) => ({
id: String(r.id),
nombre: r.nombre == null ? null : String(r.nombre),
createdAt: Number(r.created_at),
}));
}

async getRoom(id: string): Promise<StoredRoom | null> {
Expand Down Expand Up @@ -644,6 +665,7 @@ function toRoom(r: Record<string, unknown>): StoredRoom {
nombre: r.nombre == null ? null : String(r.nombre),
urlPublicada: r.url_publicada == null ? null : String(r.url_publicada),
nombrePublicado: r.nombre_publicado == null ? null : String(r.nombre_publicado),
origenId: r.origen_id == null ? null : String(r.origen_id),
// Solo "solo" cuenta como tal: null y cualquier cosa rara caen a multi, que
// es el comportamiento que ya tenían todas las salas.
modo: r.modo === "solo" ? "solo" : "multi",
Expand Down
Loading
Loading