diff --git a/packages/adapters/src/child-bots.test.ts b/packages/adapters/src/child-bots.test.ts index 6f5479e..f164116 100644 --- a/packages/adapters/src/child-bots.test.ts +++ b/packages/adapters/src/child-bots.test.ts @@ -295,7 +295,9 @@ describe("destroyBot", () => { const deleteArtifacts = vi.fn().mockResolvedValue({ count: 1 }); const transaction = vi.fn(async (callback: (tx: unknown) => Promise) => callback({ - $queryRaw: vi.fn().mockResolvedValue([]), + $queryRaw: vi + .fn() + .mockResolvedValue([{ id: "bot-1", webhookSecretId: null, spawnKey: "spawn-1" }]), chatGroup: { findMany: vi.fn().mockResolvedValue([]), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), @@ -355,6 +357,7 @@ describe("destroyBot", () => { id: "bot-1", spaceId: "workspace-1", name: "Researcher", + spawnKey: "spawn-1", deletedByUserId: "user-1", memoriesPreserved: true, }, diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 9ab1df9..78a1506 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -370,8 +370,10 @@ export async function destroyBot( await removePiBotSessions(deps.dataDir, bot.userId, bot.id); const deletion = await withTransactionRetry(() => deps.prisma.$transaction(async (tx) => { - const locked = await tx.$queryRaw>` - SELECT id, "webhookSecretId" + const locked = await tx.$queryRaw< + Array<{ id: string; webhookSecretId: string | null; spawnKey: string | null }> + >` + SELECT id, "webhookSecretId", "spawnKey" FROM bots WHERE id = ${bot.id} AND "spaceId" = ${bot.spaceId} FOR UPDATE @@ -409,6 +411,7 @@ export async function destroyBot( id: bot.id, spaceId: bot.spaceId, name: bot.name, + spawnKey: locked[0]?.spawnKey ?? null, deletedByUserId: context.userId, memoriesPreserved: !options.deleteMemories, }, diff --git a/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql new file mode 100644 index 0000000..38bffd0 --- /dev/null +++ b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql @@ -0,0 +1,3 @@ +-- A deleted bot remembers its spawn key, so helpers that create a bot idempotently (such as the +-- Studio specialist) do not bring back one the user deleted. +ALTER TABLE "bot_deletions" ADD COLUMN "spawnKey" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b9773da..c1785c1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -460,6 +460,8 @@ model BotDeletion { spaceId String space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) name String + /// The deleted bot's spawn key, so idempotent creation does not bring it back. + spawnKey String? deletedByUserId String memoriesPreserved Boolean deletedAt DateTime @default(now()) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..a5a142e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,6 +17,7 @@ export * from "./project-workspace.js"; export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; +export * from "./studio-agent.js"; export * from "./transaction-retry.js"; export * from "./user-profile.js"; export * from "./voice-credentials.js"; diff --git a/packages/db/src/studio-agent.postgres.test.ts b/packages/db/src/studio-agent.postgres.test.ts new file mode 100644 index 0000000..540b213 --- /dev/null +++ b/packages/db/src/studio-agent.postgres.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import { createDb, type PrismaClient } from "./client.js"; +import { ensureMainBot } from "./main-agent.js"; +import { + ensureStudioBot, + STUDIO_BOT_DEFAULTS, + STUDIO_BOT_TOOLS, + studioBotSpawnKey, +} from "./studio-agent.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("Studio specialist (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(name = "Ada") { + const id = `st${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + return { userId: id, homeSpaceId: spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates one Studio next to the main bot, even under concurrent calls", async () => { + const { userId, homeSpaceId } = await newUser(); + const results = await Promise.all([ + ensureStudioBot(prisma, userId), + ensureStudioBot(prisma, userId), + ensureStudioBot(prisma, userId), + ]); + expect(new Set(results.map((bot) => bot?.id)).size).toBe(1); + const main = await ensureMainBot(prisma, userId); + const studios = await prisma.bot.findMany({ where: { userId, kind: "specialist" } }); + expect(studios).toHaveLength(1); + expect(studios[0]).toMatchObject({ + id: results[0]?.id, + spaceId: homeSpaceId, + name: "Studio", + instructions: STUDIO_BOT_DEFAULTS.instructions, + tags: ["media"], + restrictBuiltinTools: true, + allowedBuiltinTools: [...STUDIO_BOT_TOOLS], + spawnKey: studioBotSpawnKey(userId), + }); + expect(main.spaceId).toBe(homeSpaceId); + // A thread makes it addressable: the main bot's specialist directory lists bots with one. + expect(await prisma.thread.count({ where: { botId: studios[0]?.id } })).toBe(1); + expect(await ensureStudioBot(prisma, userId)).toEqual(results[0]); + }); + + it("gives every member of a shared space their own Studio", async () => { + const owner = await newUser(); + const ownerStudio = await ensureStudioBot(prisma, owner.userId); + const guestId = `st${randomUUID().replaceAll("-", "")}`; + userIds.push(guestId); + await prisma.user.create({ + data: { id: guestId, name: "Guest", email: `${guestId}@rakazo.test` }, + }); + const organization = await prisma.space.findUniqueOrThrow({ + where: { id: owner.homeSpaceId }, + select: { organizationId: true }, + }); + // The organization's default-space trigger makes the guest a member of the owner's space. + await prisma.member.create({ + data: { + id: `member-${guestId}`, + organizationId: organization.organizationId, + userId: guestId, + role: "member", + createdAt: new Date(), + }, + }); + const guestStudio = await ensureStudioBot(prisma, guestId); + expect(guestStudio).not.toBeNull(); + expect(guestStudio?.id).not.toBe(ownerStudio?.id); + expect(await prisma.bot.findUniqueOrThrow({ where: { id: guestStudio!.id } })).toMatchObject({ + userId: guestId, + spawnKey: studioBotSpawnKey(guestId), + }); + }); + + it("does not bring back a Studio the user archived or deleted", async () => { + const archiver = await newUser(); + const archived = await ensureStudioBot(prisma, archiver.userId); + await prisma.bot.update({ where: { id: archived!.id }, data: { archivedAt: new Date() } }); + await expect(ensureStudioBot(prisma, archiver.userId)).resolves.toBeNull(); + expect(await prisma.bot.count({ where: { userId: archiver.userId, name: "Studio" } })).toBe(1); + + const deleter = await newUser(); + const deleted = await ensureStudioBot(prisma, deleter.userId); + const row = await prisma.bot.findUniqueOrThrow({ where: { id: deleted!.id } }); + // What destroyBot records when the user deletes a bot. + await prisma.botDeletion.create({ + data: { + id: row.id, + spaceId: row.spaceId, + name: row.name, + spawnKey: row.spawnKey, + deletedByUserId: deleter.userId, + memoriesPreserved: true, + }, + }); + await prisma.bot.delete({ where: { id: row.id } }); + await expect(ensureStudioBot(prisma, deleter.userId)).resolves.toBeNull(); + expect(await prisma.bot.count({ where: { userId: deleter.userId, name: "Studio" } })).toBe(0); + }); +}); diff --git a/packages/db/src/studio-agent.ts b/packages/db/src/studio-agent.ts new file mode 100644 index 0000000..5e24154 --- /dev/null +++ b/packages/db/src/studio-agent.ts @@ -0,0 +1,91 @@ +import { MediaOperationSchema } from "@rakazo/contracts"; +import type { PrismaClient } from "./client.js"; +import { ensureMainBot } from "./main-agent.js"; +import { createRepos } from "./repos.js"; +import { requireMembership } from "./scope.js"; + +/** + * Idempotency key of the user's Studio specialist. Spawn keys are unique per space, so it + * carries the user id to stay unique in a space several users share. + */ +export function studioBotSpawnKey(userId: string): string { + return `media:studio:${userId}`; +} + +/** Builtin tools Studio keeps: every media operation plus files, questions and memory. */ +export const STUDIO_BOT_TOOLS = [ + ...MediaOperationSchema.options, + "list_files", + "read_file", + "attach_file", + "ask_user", + "message_user", + "remember", + "web_search", + "web_fetch", + "skill_read", +] as const; + +/** Product text: the main bot reads the description when routing, Studio follows the instructions. */ +export const STUDIO_BOT_DEFAULTS = { + name: "Studio", + title: "Media producer", + description: + "Makes and edits images, video and audio: cuts, reframes and exports for every format, transcribes and subtitles.", + instructions: [ + "You make and edit images, video and audio with the media tools, then hand the result back in a sentence or two.", + "", + "Work like an editor:", + "- Start from where the piece will run. Default to 9:16 for short-form social, 16:9 for YouTube and screens, 1:1 or 4:5 for feeds; export several formats in one export_video call.", + "- Hook in the first two seconds: open on the strongest moment, not the setup.", + "- Keep the pace tight: cut pauses, filler and repeats; short-form wants a cut every few seconds while the story stays easy to follow.", + "- Respect safe areas in 9:16: keep faces, text and subtitles out of the top 15% and bottom 20%, where the platform's buttons sit. When reframing, keep the subject centred.", + "- Most social video plays muted: transcribe speech, then burn in short subtitles.", + "- Keep the source: every edit makes a new file, so try a cut and refine it.", + "", + "Media jobs run in the background and the user sees each one's card. Set continue_after when you need a result for your next step. Ask only when the purpose or format is unclear and a wrong guess would waste a long render.", + ].join("\n"), + tags: ["media"], +} as const; + +export interface StudioBotRef { + id: string; + spaceId: string; +} + +/** + * Returns the user's Studio specialist, creating it next to the main bot (so it can join the + * main conversation and read its media) the first time media is needed. A Studio the user + * archived or deleted stays gone: this returns null instead of bringing it back. + */ +export async function ensureStudioBot( + prisma: PrismaClient, + userId: string, +): Promise { + const main = await ensureMainBot(prisma, userId); + const spawnKey = studioBotSpawnKey(userId); + const existing = await prisma.bot.findUnique({ + where: { spaceId_spawnKey: { spaceId: main.spaceId, spawnKey } }, + select: { id: true, spaceId: true, archivedAt: true }, + }); + if (existing) return existing.archivedAt ? null : { id: existing.id, spaceId: existing.spaceId }; + const deleted = await prisma.botDeletion.findFirst({ + where: { spaceId: main.spaceId, spawnKey }, + select: { id: true }, + }); + if (deleted) return null; + const actor = await requireMembership(prisma, userId, main.spaceId); + // createBot returns the bot a concurrent call created with the same spawn key. + const bot = await createRepos(prisma).createBot(actor, { + name: STUDIO_BOT_DEFAULTS.name, + title: STUDIO_BOT_DEFAULTS.title, + description: STUDIO_BOT_DEFAULTS.description, + instructions: STUDIO_BOT_DEFAULTS.instructions, + notifyOnFinish: true, + spawnKey, + tags: [...STUDIO_BOT_DEFAULTS.tags], + restrictBuiltinTools: true, + allowedBuiltinTools: [...STUDIO_BOT_TOOLS], + }); + return { id: bot.id, spaceId: bot.spaceId }; +} diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..948ec91 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -108,6 +108,7 @@ async function main() { "packages/testkit/src/user-profile.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", + "packages/db/src/studio-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts",