import { createHash, randomBytes, randomUUID } from "node:crypto"; import { implement, ORPCError } from "@orpc/server"; import { type AdapterContext, type AgentHomeStore, type ArtifactStore, type ConnectorCatalogItem, computerControlExpireJobKey, type JobPublisher, type MemoryStore, messagingDeliverJob, routineJobKey, routineWakeupJob, runContinueJob, runJobKey, type SandboxProvider, } from "@rakazo/adapter-kit"; import { acquireComputerExecutionLease, applyTeachingDesktopInput, archiveBot, buildMcpCredentialBlob, buildModelConnectPlaintext, type ComposioProvider, ComputerBusyError, type ComputerExecutionLease, type ConnectorRegistry, checkpointAndRecordComputerWorkspace, computerSupportsUpdate, createVoiceProvider, deletePushToken, deploymentAutoReviewDefault, destroyBot, displayBotWorkspacePath, type EncryptedSecretStore, enqueueTakeoverContinuation, expireComputerControl, hasActiveComputerControl, isAutoReviewCheckerConfigured, isSandboxGoneError, isScratchpadStatus, listPiCatalog, listScratchpadItems, McpOAuthBroker, type MemoryProviderResolver, mapScratchpadItem, modelCredentialDto, type PiOAuthLogins, planLiveConnectionSync, prepareApiInstall, prepareMemoryProviderConnection, probeOpenAiCompatibleModels, provisionComputer, type RemoteConnectorDependencies, releaseComputerExecutionLease, replaceComputer, resolveAutoReviewChecker, resolveBotWorkspacePath, sanitizeComposioError, savePushToken, scheduleComputerControlExpiry, scheduleComputerSleep, screenLeaseIdForRun, scriptedCatalogEntry, serializeModelSecret, takeoverLeaseMs, toComputerRef, toStringRecord, touchRunningComputer, verifyMcpInstall, } from "@rakazo/adapters"; import type { Auth } from "@rakazo/auth"; import { type Actor, appContract, type ComputerStatus, isAttachmentImageMimeType, type McpServer, type Me, OPENAI_COMPATIBLE_PROVIDER_ID, type SpaceNavigation, } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES, AttachmentValidationError, containsSecret, expandSkillReferencesInPrompt, hasMixedOneShotSchedule, isOneShotRoutineCrons, nextCronDateAcrossStrict, } from "@rakazo/core"; import { appendEventInTransaction, createGroupRepos, createRepos, createSpaceForMember, createThreadMessageInTransaction, deleteUnreferencedCredentialSecret, findDefaultModelCredential, findDefaultVoiceCredential, findModelCredential, findSpaceMemoryConfig, formatMessagingLinkCode, InvalidSpaceNameError, IsolationError, issueMessagingLinkCode, lockOwnedGroup, newestModelCredentialOrder, newestVoiceCredentialOrder, Prisma, type PrismaClient, parseComputerMode, SpaceLimitError, selectSpaceModelPreference, selectSpaceVoicePreference, type ThreadEvents, touchGroupUpdatedAt, } from "@rakazo/db"; import { createAgentSkillsService } from "./agent-skills.js"; import { copyBotAvatarImage, createOwnedArtifact, getOwnedArtifact, getSpaceArtifact, removeOwnedArtifact, } from "./artifacts.js"; import { executionBlocksUserTakeover, resolveBusyBotName, toComputerStatus, } from "./computer-status.js"; import { buildMcpUpdateMaterial } from "./mcp-material.js"; import { chooseFocus, markAppConnected, startOnboarding } from "./onboarding.js"; import { listSpaceRuns } from "./runs.js"; import { addScreenProxyCapability } from "./screen-proxy.js"; import { querySpaceSearch } from "./search.js"; import { withSerializableRetry } from "./serializable-retry.js"; import { applyServerUpdate, checkServerUpdate, readServerUpdateStatus, type UpdaterProxyConfig, UpdaterProxyError, } from "./server-update.js"; import { assertTeachingSendAllowed, createTaughtSkillsService } from "./taught-skills.js"; import { isPeerRun, loadAllMessages, loadMessagePage } from "./thread-message-pages.js"; import { reactToThreadMessage, resolveThreadTarget, sendThreadMessage, setThreadUnreadState, stopThreadRuns, threadHead, threadSnapshot, } from "./thread-target.js"; import { listVoiceCatalog, loadDefaultVoiceCredential, loadVoiceCredential, persistVoiceCredential, prepareVoice, toVoiceCredential, toVoiceStatus, voiceContext, } from "./voice.js"; const MAX_COMPUTER_TEXT_FILE_BYTES = 2 * 1024 * 1024; const THREAD_MESSAGE_PAGE_SIZE = 100; const EXPORT_MESSAGE_PAGE_SIZE = 500; async function reconcilePendingConnections( prisma: PrismaClient, owner: Pick, connectorId: string, connectedProviders: string[], ): Promise { const connectedProviderKeys = new Set( connectedProviders.map((provider) => provider.trim().toLowerCase()), ); const rows = ( await prisma.connection.findMany({ where: { spaceId: owner.spaceId, userId: owner.userId, connectorId, status: { in: ["pending", "connected"] }, }, select: { id: true, provider: true, displayName: true, status: true }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], }) ).filter((row: { provider: string }) => connectedProviderKeys.has(row.provider.trim().toLowerCase()), ); const sync = planLiveConnectionSync(rows, connectedProviders); const updates = [ ...(sync.connectIds.length > 0 ? [ prisma.connection.updateMany({ where: { id: { in: sync.connectIds }, spaceId: owner.spaceId, userId: owner.userId, status: "pending", }, data: { status: "connected" }, }), ] : []), ...(sync.revokeIds.length > 0 ? [ prisma.connection.updateMany({ where: { id: { in: sync.revokeIds }, spaceId: owner.spaceId, userId: owner.userId, status: "pending", }, data: { status: "revoked" }, }), ] : []), ]; if (updates.length > 0) await prisma.$transaction(updates); } function computerContext(actor: Actor, botId: string, operationId: string): AdapterContext { return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId, botId, signal: new AbortController().signal, }; } function mcpServerDto( row: { id: string; spaceId: string; slug: string; name: string; description: string; transport: string; endpoint: string | null; command: string | null; args: unknown; env: unknown; headers: unknown; secretId: string | null; enabled: boolean; revision: number; createdAt: Date; updatedAt: Date; }, oauthStatus: McpServer["oauthStatus"] = "none", ): McpServer { const args = Array.isArray(row.args) ? row.args.filter((item): item is string => typeof item === "string") : []; const envKeys = row.env && typeof row.env === "object" && !Array.isArray(row.env) ? Object.keys(row.env) : []; const headerKeys = row.headers && typeof row.headers === "object" && !Array.isArray(row.headers) ? Object.keys(row.headers) : []; return { id: row.id, spaceId: row.spaceId, slug: row.slug, name: row.name, description: row.description, transport: row.transport as McpServer["transport"], endpoint: row.endpoint, command: row.command, args, envKeys, headerKeys, hasSecret: row.secretId !== null, oauthStatus, enabled: row.enabled, revision: row.revision, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function connectionContext( actor: Pick, operationId: string, signal?: AbortSignal, ): AdapterContext { return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId, signal: signal ?? new AbortController().signal, }; } function mcpAssignmentDto(row: { id: string; botId: string; serverId: string; allowAllTools: boolean; allowedTools: unknown; createdAt: Date; updatedAt: Date; }) { return { id: row.id, botId: row.botId, serverId: row.serverId, allowAllTools: row.allowAllTools, allowedTools: Array.isArray(row.allowedTools) ? row.allowedTools.filter((item): item is string => typeof item === "string") : [], createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } export interface RouterDeps { prisma: PrismaClient; events: ThreadEvents; auth: Auth; jobs: JobPublisher; sandbox: SandboxProvider; memory: MemoryStore; memoryProviders: MemoryProviderResolver; home: AgentHomeStore; secrets: EncryptedSecretStore; oauthLogins: PiOAuthLogins; composio?: ComposioProvider; mcpOAuth?: McpOAuthBroker; connectors: ConnectorRegistry; remoteConnectors?: RemoteConnectorDependencies; artifacts: ArtifactStore; dataDir: string; /** Present when the external messaging surface is enabled. */ messaging?: { enabled: boolean; providers: string[]; openSignup: boolean }; env: { defaultProvider: string; defaultModel: string; deploymentModelKey?: string; webOrigin: string; screenProxySecret: string; sandboxProvider: string; gitSha?: string; updaterUrl?: string; updaterToken?: string; imageTag?: string; }; } export function createRouter(deps: RouterDeps) { const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); const repos = createRepos(deps.prisma); const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const groupRepos = createGroupRepos(deps.prisma); const taughtSkills = createTaughtSkillsService({ prisma: deps.prisma, events: deps.events, jobs: deps.jobs, sandbox: deps.sandbox, home: deps.home, dataDir: deps.dataDir, }); const agentSkills = createAgentSkillsService(deps.prisma); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); return next({ context: { ...context, actor: context.actor } }); }); return os.router({ health: os.health.handler(async () => ({ ok: true as const, version: "0.1.0" })), me: authed.me.handler(async ({ context }): Promise => meDto(deps, context.actor)), preferences: { update: authed.preferences.update.handler(async ({ context, input }): Promise => { await deps.prisma.user.update({ where: { id: context.actor.userId }, data: { avatarStyle: input.avatarStyle }, }); return meDto(deps, context.actor); }), }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), ), create: authed.spaces.create.handler(async ({ context, input }) => { let space: { id: string; name: string }; try { space = await createSpaceForMember(deps.prisma, { currentSpaceId: context.actor.spaceId, userId: context.actor.userId, name: input.name, }); } catch (error) { if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) { throw new ORPCError("BAD_REQUEST", { message: error.message }); } throw error; } return { id: space.id, name: space.name, isDefault: false, bots: [], groups: [], botSections: [], }; }), }, bootstrap: authed.bootstrap.handler(async ({ context, input }) => { const actor = context.actor; const [me, navigation, archivedBots, archivedGroups] = await Promise.all([ meDto(deps, actor), spaceNavigationDto(deps, actor, repos, groupRepos), repos.listBots(actor, { archived: true }), groupRepos.listGroups(actor, { archived: true }), ]); const { bots, groups, botSections } = navigation.current; const active = bots.find((bot) => bot.id === input.botId) ?? bots[0]; const [thread, routines] = active ? await Promise.all([ resolveThreadTarget(deps.prisma, actor, { botId: active.id }).then((target) => threadSnapshot(deps, target), ), listRoutinesDto(deps, actor, active.id), ]) : [null, []]; return { me, bots, groups, botSections, archivedBots, archivedGroups, thread, routines, spaces: navigation.spaces, }; }), deployment: { get: authed.deployment.get.handler(async ({ context }) => { if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); return deploymentDto(deps.prisma, deps.env.sandboxProvider); }), update: authed.deployment.update.handler(async ({ context, input }) => { if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); if (input.computerHost === "this-mac" && deps.env.sandboxProvider !== "docker") { throw new ORPCError("BAD_REQUEST", { message: "This Mac mode is only available when SANDBOX_PROVIDER=docker on a personal local app.", }); } await deps.prisma.deploymentSettings.upsert({ where: { id: "default" }, create: { id: "default", ownerUserId: context.actor.userId, signupsEnabled: input.signupsEnabled ?? true, signupAllowlist: (input.signupAllowlist ?? []).join(","), signupPolicyInitialized: true, computerHost: input.computerHost ?? undefined, }, update: { ...(input.signupsEnabled === undefined ? {} : { signupsEnabled: input.signupsEnabled }), ...(input.signupAllowlist ? { signupAllowlist: input.signupAllowlist.join(",") } : {}), ...(input.signupsEnabled === undefined && input.signupAllowlist === undefined ? {} : { signupPolicyInitialized: true }), ...(input.computerHost === undefined ? {} : { computerHost: input.computerHost }), }, }); return deploymentDto(deps.prisma, deps.env.sandboxProvider); }), }, updater: { status: authed.updater.status.handler(async ({ context }) => { if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); return readServerUpdateStatus(updaterConfig(deps)); }), check: authed.updater.check.handler(async ({ context, input }) => { if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); try { return await checkServerUpdate(updaterConfig(deps), input); } catch (error) { mapUpdaterError(error); } }), apply: authed.updater.apply.handler(async ({ context, input }) => { if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); try { return await applyServerUpdate(updaterConfig(deps), input); } catch (error) { mapUpdaterError(error); } }), }, models: { list: authed.models.list.handler(async () => [...listPiCatalog(), scriptedCatalogEntry]), credentials: authed.models.credentials.handler(async ({ context }) => { const rows = await deps.prisma.userModelCredential.findMany({ where: { userId: context.actor.userId }, include: { preferences: { where: { userId: context.actor.userId, spaceId: context.actor.spaceId }, }, }, orderBy: newestModelCredentialOrder, }); const compatibleRows = rows.filter((row) => row.provider === OPENAI_COMPATIBLE_PROVIDER_ID); const secrets = compatibleRows.length ? await deps.prisma.secret.findMany({ where: { id: { in: compatibleRows.map((row) => row.secretId) }, userId: context.actor.userId, spaceId: null, }, select: { id: true, ciphertext: true }, }) : []; const ciphertextById = new Map(secrets.map((secret) => [secret.id, secret.ciphertext])); return rows.map((row) => { const preference = row.preferences[0]; const selected = { ...row, isDefault: preference?.isDefault ?? false, defaultModel: preference?.modelId ?? null, }; const ciphertext = ciphertextById.get(row.secretId); if (!ciphertext) return modelCredentialDto(selected); try { return modelCredentialDto(selected, deps.secrets.load(ciphertext, row.secretId)); } catch { return modelCredentialDto(selected); } }); }), connect: authed.models.connect.handler(async ({ context, input }) => { let plaintext: string; try { plaintext = buildModelConnectPlaintext(input); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Invalid model connection", }); } return persistModelCredential(deps, context.actor, { provider: input.provider, plaintext, label: input.label, modelId: input.modelId, signal: context.signal, }); }), probeOpenAiCompatible: authed.models.probeOpenAiCompatible.handler( async ({ context, input }) => { try { const models = await probeOpenAiCompatibleModels(input, fetch, context.signal); return { models }; } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Could not list models", }); } }, ), beginOAuth: authed.models.beginOAuth.handler(async ({ context, input }) => { return deps.oauthLogins.begin({ userId: context.actor.userId, spaceId: context.actor.spaceId, provider: input.provider, modelId: input.modelId, label: input.label, signal: context.signal, }); }), submitOAuthCode: authed.models.submitOAuthCode.handler(async ({ context, input }) => { return deps.oauthLogins.submit(input.loginId, context.actor, input.code); }), completeOAuth: authed.models.completeOAuth.handler(async ({ context, input }) => { const result = await deps.oauthLogins.complete(input.loginId, { userId: context.actor.userId, spaceId: context.actor.spaceId, }); return result.status === "connected" ? { status: "ready" as const } : result; }), finishOAuth: authed.models.finishOAuth.handler(async ({ context, input }) => { throwIfAborted(context.signal); const result = await deps.oauthLogins.finish( input.loginId, context.actor, async (login) => { return persistModelCredential(deps, context.actor, { provider: login.provider, plaintext: serializeModelSecret({ kind: "oauth", credential: login.credential }), label: login.label ?? "ChatGPT Plus/Pro", modelId: login.modelId, signal: login.signal, }); }, ); if (result.status === "pending") { throw new ORPCError("CONFLICT", { message: "Sign-in has not finished yet." }); } if (result.status === "error") { throw new ORPCError("NOT_FOUND", { message: result.error }); } return result.value; }), cancelOAuth: authed.models.cancelOAuth.handler(async ({ context, input }) => { await deps.oauthLogins.cancel(input.loginId, context.actor); return { ok: true as const }; }), setDefault: authed.models.setDefault.handler(async ({ context, input }) => { await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { const credential = await tx.userModelCredential.findFirst({ where: { userId: context.actor.userId, provider: input.provider }, orderBy: newestModelCredentialOrder, }); if (!credential) { throw new ORPCError("NOT_FOUND", { message: `No model credential is connected for ${input.provider}.`, }); } await selectSpaceModelPreference(tx, context.actor, credential.id, input.modelId); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); return { ok: true as const }; }), }, bots: { list: authed.bots.list.handler(async ({ context }) => repos.listBots(context.actor)), listArchived: authed.bots.listArchived.handler(async ({ context }) => repos.listBots(context.actor, { archived: true }), ), get: authed.bots.get.handler(async ({ context, input }) => { const found = (await repos.listBots(context.actor)).find((bot) => bot.id === input.botId); if (!found) throw new IsolationError(); return found; }), create: authed.bots.create.handler(async ({ context, input }) => repos.createBot(context.actor, input), ), duplicate: authed.bots.duplicate.handler(async ({ context, input }) => { const source = await repos.getBot(context.actor, input.botId); const duplicate = await repos.createBot(context.actor, { name: duplicateBotName(source.name), title: source.title, description: source.description, instructions: source.instructions, notifyOnFinish: source.notifyOnFinish, color: source.color, avatarShape: source.avatarShape, computerMode: source.computer?.scope === "dedicated" ? "dedicated" : "team", modelProvider: source.modelProvider, modelId: source.modelId, thinkingLevel: source.thinkingLevel, }); if (source.avatarImageArtifactId) { await copyBotAvatarImage( { prisma: deps.prisma, artifacts: deps.artifacts }, context.actor, { sourceBotId: source.id, sourceArtifactId: source.avatarImageArtifactId, targetBotId: duplicate.id, }, ); } const assignments = await deps.prisma.botMcpServer.findMany({ where: { botId: source.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (assignments.length) { await deps.prisma.botMcpServer.createMany({ data: assignments.map((assignment) => ({ spaceId: context.actor.spaceId, userId: context.actor.userId, botId: duplicate.id, serverId: assignment.serverId, allowAllTools: assignment.allowAllTools, allowedTools: assignment.allowedTools as Prisma.InputJsonValue, })), }); } if (!source.avatarImageArtifactId) return duplicate; const copied = (await repos.listBots(context.actor)).find((bot) => bot.id === duplicate.id); return copied ?? duplicate; }), reorder: authed.bots.reorder.handler(async ({ context, input }) => { await repos.reorderBots(context.actor, input.botIds); return { ok: true as const }; }), update: authed.bots.update.handler(async ({ context, input }) => { const existing = await repos.getBot(context.actor, input.botId); if (input.sectionId) { const section = await deps.prisma.botSection.findFirst({ where: { id: input.sectionId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }); if (!section) throw new IsolationError(); } if (input.modelProvider && input.modelId) { const credential = await findModelCredential( deps.prisma, context.actor, input.modelProvider, ); if (!credential) { throw new ORPCError("BAD_REQUEST", { message: "Connect that model provider first" }); } const knownModels = [...listPiCatalog(), scriptedCatalogEntry]; const inCatalog = knownModels.some( (item) => item.provider === input.modelProvider && item.id === input.modelId, ); if (!inCatalog && credential.defaultModel !== input.modelId) { throw new ORPCError("BAD_REQUEST", { message: "Unknown model for that provider" }); } } const thinkingLevel = input.thinkingLevel; if (input.avatarImageArtifactId) { const artifact = await deps.prisma.artifact.findFirst({ where: { id: input.avatarImageArtifactId, botId: input.botId, groupId: null, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true, mimeType: true }, }); if (!artifact) throw new IsolationError(); if (!isAttachmentImageMimeType(artifact.mimeType)) { throw new ORPCError("BAD_REQUEST", { message: "Avatar must be an image" }); } } if (input.thinkingLevel) { const provider = input.modelProvider !== undefined ? input.modelProvider : existing.modelProvider; const modelId = input.modelId !== undefined ? input.modelId : existing.modelId; const me = await meDto(deps, context.actor); const effectiveProvider = provider ?? me.defaultProvider; const effectiveModelId = modelId ?? me.defaultModel; if (effectiveProvider && effectiveModelId) { const entry = listPiCatalog().find( (item) => item.provider === effectiveProvider && item.id === effectiveModelId, ); const allowed = entry?.thinkingLevels; if (allowed && !allowed.includes(input.thinkingLevel)) { throw new ORPCError("BAD_REQUEST", { message: `Thinking level must be one of: ${allowed.join(", ")}`, }); } } } const previousImageId = existing.avatarImageArtifactId; await deps.prisma.bot.update({ where: { id: input.botId }, data: { name: input.name, title: input.title, description: input.description, instructions: input.instructions, notifyOnFinish: input.notifyOnFinish, color: input.color, avatarShape: input.avatarShape, avatarImageArtifactId: input.avatarImageArtifactId, pinned: input.pinned, memoryScope: input.memoryScope, sectionId: input.sectionId, voiceId: input.voiceId, autoSpeak: input.autoSpeak, ...(input.modelProvider !== undefined ? { modelProvider: input.modelProvider, modelId: input.modelId ?? null } : {}), ...(input.thinkingLevel !== undefined ? { thinkingLevel } : {}), }, }); if ( input.avatarImageArtifactId !== undefined && previousImageId && previousImageId !== input.avatarImageArtifactId ) { await removeOwnedArtifact( { prisma: deps.prisma, artifacts: deps.artifacts }, context.actor, { botId: input.botId, artifactId: previousImageId }, ); } const bots = await repos.listBots(context.actor); const bot = bots.find((b) => b.id === input.botId); if (!bot) throw new IsolationError(); return bot; }), setComputer: authed.bots.setComputer.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); const currentMode = bot.computer.scope === "dedicated" ? "dedicated" : "team"; if (currentMode === input.mode) { return repos.setBotComputer(context.actor, bot.id, input.mode); } const claimed = await deps.prisma.bot.updateMany({ where: { id: bot.id, computerSwitching: false }, data: { computerSwitching: true }, }); if (claimed.count !== 1) throw new ORPCError("CONFLICT"); try { const active = await deps.prisma.run.findFirst({ where: { botId: bot.id, status: { in: [...ACTIVE_RUN_STATUSES] } }, select: { id: true }, }); if (active) { throw new ORPCError("BAD_REQUEST", { message: "Stop the bot first" }); } if (bot.computer.controlBotId === bot.id && hasActiveComputerControl(bot.computer)) { throw new ORPCError("BAD_REQUEST", { message: "Release the computer first" }); } if (bot.computer.scope === "dedicated" && bot.computer.providerRef) { const ctx = computerContext(context.actor, bot.id, "computer.switch"); const ref = toComputerRef(bot.computer); if (bot.computer.state === "running") { await checkpointAndRecordComputerWorkspace(deps, bot.computer, ref, ctx); await deps.sandbox.stop(ref, ctx); } await deps.prisma.computerExecutionLease.deleteMany({ where: { computerId: bot.computer.id, botId: bot.id }, }); await deps.prisma.computer.update({ where: { id: bot.computer.id }, data: { state: "stopped", controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: null, executionRunId: null, executionBotId: null, executionLeaseExpiresAt: null, }, }); } return await repos.setBotComputer(context.actor, bot.id, input.mode); } finally { await deps.prisma.bot.updateMany({ where: { id: bot.id }, data: { computerSwitching: false }, }); } }), archive: authed.bots.archive.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); await archiveBot( { prisma: deps.prisma, sandbox: deps.sandbox, home: deps.home, jobs: deps.jobs, artifacts: deps.artifacts, dataDir: deps.dataDir, }, bot, computerContext(context.actor, bot.id, "archive"), ); return { ok: true as const }; }), restore: authed.bots.restore.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); if (!bot.archivedAt) return { ok: true as const }; await deps.prisma.bot.update({ where: { id: bot.id }, data: { archivedAt: null } }); return { ok: true as const }; }), remove: authed.bots.remove.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId, { includeArchived: true }); await destroyBot( { prisma: deps.prisma, sandbox: deps.sandbox, home: deps.home, jobs: deps.jobs, artifacts: deps.artifacts, dataDir: deps.dataDir, }, bot, { operationId: "destroy", traceId: "destroy", spaceId: context.actor.spaceId, userId: context.actor.userId, signal: new AbortController().signal, }, { deleteMemories: input.deleteMemories }, ); return { ok: true as const }; }), rotateWebhookSecret: authed.bots.rotateWebhookSecret.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); const plaintext = randomBytes(32).toString("base64url"); const stored = await deps.secrets.put(plaintext, { operationId: "bots.rotateWebhookSecret", traceId: "bots.rotateWebhookSecret", spaceId: context.actor.spaceId, userId: context.actor.userId, signal: context.signal ?? new AbortController().signal, }); await deps.prisma.$transaction(async (tx) => { const previousSecretId = bot.webhookSecretId; await tx.secret.create({ data: { id: stored.id, userId: context.actor.userId, spaceId: context.actor.spaceId, kind: "webhook", ciphertext: stored.ciphertext, }, }); await tx.bot.update({ where: { id: bot.id }, data: { webhookSecretId: stored.id }, }); if (previousSecretId) { await tx.secret.deleteMany({ where: { id: previousSecretId, spaceId: context.actor.spaceId, userId: context.actor.userId, kind: "webhook", }, }); } }); return { secret: plaintext, path: `/api/v1/bots/${bot.id}/webhook`, webhookConfigured: true as const, }; }), }, groups: { create: authed.groups.create.handler(async ({ context, input }) => groupRepos.createGroup(context.actor, input), ), list: authed.groups.list.handler(async ({ context }) => groupRepos.listGroups(context.actor)), listArchived: authed.groups.listArchived.handler(async ({ context }) => groupRepos.listGroups(context.actor, { archived: true }), ), get: authed.groups.get.handler(async ({ context, input }) => { const group = await groupRepos.getGroup(context.actor, input.groupId); return { ...groupRepos.mapGroup(group), messages: ( await loadMessagePage( deps.prisma, group.thread!.id, undefined, THREAD_MESSAGE_PAGE_SIZE, ) ).messages, }; }), duplicate: authed.groups.duplicate.handler(async ({ context, input }) => { const source = await groupRepos.getGroup(context.actor, input.groupId); return groupRepos.createGroup(context.actor, { name: duplicateBotName(source.name), botIds: source.members.map((member) => member.bot.id), }); }), update: authed.groups.update.handler(async ({ context, input }) => { if (input.sectionId) { const section = await deps.prisma.botSection.findFirst({ where: { id: input.sectionId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }); if (!section) throw new IsolationError(); } const updated = await groupRepos.updateGroup(context.actor, input); await Promise.all( updated.cancelledRunIds.map((runId) => deps.jobs.cancel(runJobKey(runId)).catch(() => undefined), ), ); return updated.group; }), archive: authed.groups.archive.handler(async ({ context, input }) => { const archived = await groupRepos.archiveGroup(context.actor, input.groupId); await Promise.all( archived.cancelledRunIds.map((runId) => deps.jobs.cancel(runJobKey(runId)).catch(() => undefined), ), ); await Promise.all( archived.computers.map(async (computer) => { if (!computer.providerRef || !computer.executionBotId) return; await deps.sandbox .releaseScreen?.(toComputerRef(computer), { operationId: "stop", traceId: "stop", spaceId: context.actor.spaceId, userId: context.actor.userId, botId: computer.executionBotId, signal: new AbortController().signal, }) .catch(() => undefined); }), ); return { ok: true as const }; }), restore: authed.groups.restore.handler(async ({ context, input }) => { await groupRepos.restoreGroup(context.actor, input.groupId); return { ok: true as const }; }), remove: authed.groups.remove.handler(async ({ context, input }) => { const removed = await groupRepos.removeGroup(context.actor, input.groupId); const cleanup = await Promise.allSettled( removed.artifactStorageKeys.map((storageKey) => deps.artifacts.remove( storageKey, computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`), ), ), ); for (const result of cleanup) { if (result.status === "rejected") console.error("group artifact cleanup", result.reason); } return { ok: true as const }; }), }, botSections: { list: authed.botSections.list.handler(async ({ context }) => repos.listBotSections(context.actor), ), create: authed.botSections.create.handler(async ({ context, input }) => repos.createBotSection(context.actor, input), ), }, threads: { head: authed.threads.head.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); return threadHead(deps.prisma, target); }), get: authed.threads.get.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); return threadSnapshot(deps, target); }), messages: authed.threads.messages.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); return loadMessagePage( deps.prisma, target.threadId, input.before, THREAD_MESSAGE_PAGE_SIZE, input.around, input.includePeerRuns, input.includePeerReceipts, ); }), subscribe: authed.threads.subscribe.handler(async function* ({ context, input }) { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const peerRunCache = new Map>(); for await (const event of deps.events.follow( target.threadId, input.cursor, context.signal, )) { if (await isPeerRun(deps.prisma, event.runId, peerRunCache)) { // Keep terminal peer-run events so clients can clear working state. // Keep compact peer receipts for mobile; drop peer activity/replies. const isTerminal = event.type === "run.completed" || event.type === "run.failed" || event.type === "run.cancelled"; const blocks = event.payload.blocks; const isReceipt = (event.type === "thread.message.created" || event.type === "thread.message.updated") && Array.isArray(blocks) && blocks.some( (block) => !!block && typeof block === "object" && "kind" in block && (block.kind === "bot_message_received" || block.kind === "bot_message_sent"), ); if (!isTerminal && !isReceipt) continue; } yield event; } }), send: authed.threads.send.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); if (target.kind === "bot") { await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId); } return sendThreadMessage(deps, context.actor, target, input); }), react: authed.threads.react.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const result = await reactToThreadMessage( deps, context.actor, target, input.messageId, input.thumbsUp, ); if (result.eventSeq != null) { await deps.events.notify(target.threadId, result.eventSeq).catch((error) => { console.error("thread reaction realtime notification", error); }); } if (result.runId) { await deps.jobs.enqueue(runContinueJob(result.runId)).catch((error) => { console.error("thread reaction enqueue", error); }); } return { ok: true as const }; }), stop: authed.threads.stop.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); await stopThreadRuns(deps, context.actor, target); return { ok: true as const }; }), clear: authed.threads.clear.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0]; if (!contextBotId) throw new IsolationError(); const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({ spaceId: context.actor.spaceId, threadId: target.threadId, botId: contextBotId, ...(target.kind === "group" ? { groupId: target.groupId } : {}), }); const [configuredMemory] = await Promise.all([ target.kind === "bot" ? deps.memoryProviders.resolve(context.actor.spaceId).catch((error) => { console.error("semantic memory resolution after thread clear failed", error); return null; }) : Promise.resolve(null), Promise.all( cancelledRunIds.map((runId) => deps.jobs.cancel(runJobKey(runId)).catch(() => undefined), ), ), ]); // Durable memories remain in their Space-private containers. Clear only removes // conversation-derived summaries from the previous generation; including the new // generation also covers a compaction job that began just after the clear committed. if (configuredMemory && target.kind === "bot") { // Best effort: the conversation rows are already deleted, so failing the clear here // would help nothing — a failed purge only leaves stale summaries recallable. try { const purged = await configuredMemory.provider.purgeHistory( { botId: target.botId, generations: [ Math.max(0, historyCompactionGeneration - 1), historyCompactionGeneration, ], }, computerContext(context.actor, target.botId, `thread-clear:${target.threadId}`), ); if (!purged.ok) { console.error("semantic memory purge after thread clear failed", purged.error); } } catch (error) { console.error("semantic memory purge after thread clear failed", error); } } return { ok: true as const }; }), followUp: authed.threads.followUp.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); if (target.kind === "bot") { await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId); const sent = await deps.events.sendUserMessage({ spaceId: context.actor.spaceId, threadId: target.threadId, botId: target.botId, userId: context.actor.userId, blocks: [{ kind: "text", text: input.text }], prompt: input.text, trigger: "follow_up", }); if (sent.taskId && sent.runId) { await deps.jobs.enqueue(runContinueJob(sent.runId)).catch((error) => { console.error("follow-up enqueue", error); }); } return { ok: true as const }; } const committed = await deps.prisma.$transaction(async (tx) => { await lockOwnedGroup(tx, context.actor, target.groupId); const group = await tx.chatGroup.findFirst({ where: { id: target.groupId, archivedAt: null, thread: { id: target.threadId }, }, include: { members: { orderBy: { createdAt: "asc" } } }, }); const botId = group?.members[0]?.botId; if (!botId) throw new IsolationError(); const blocks = [{ kind: "text" as const, text: input.text }]; const message = await createThreadMessageInTransaction(tx, { threadId: target.threadId, role: "user", blocks, }); const active = await tx.run.findFirst({ where: { threadId: target.threadId, botId, status: { in: [...ACTIVE_RUN_STATUSES] }, }, select: { id: true }, }); let run: { id: string } | null = null; if (!active) { const task = await tx.task.create({ data: { spaceId: context.actor.spaceId, botId, threadId: target.threadId, userId: context.actor.userId, prompt: input.text, status: "queued", }, }); run = await tx.run.create({ data: { spaceId: context.actor.spaceId, botId, threadId: target.threadId, taskId: task.id, userId: context.actor.userId, status: "queued", trigger: "follow_up", sourceMessageId: message.id, }, select: { id: true }, }); await tx.message.update({ where: { id: message.id }, data: { runId: run.id } }); } else { await tx.steeringMessage.create({ data: { messageId: message.id, botId, userId: context.actor.userId, runId: active.id, }, }); await tx.message.update({ where: { id: message.id }, data: { runId: active.id } }); } const event = await appendEventInTransaction(tx, { spaceId: context.actor.spaceId, threadId: target.threadId, botId, type: "thread.message.created", runId: run?.id ?? active?.id, payload: { messageId: message.id, role: "user", blocks }, }); await touchGroupUpdatedAt(tx, target.groupId); return { runId: run?.id, eventSeq: event.seq }; }); await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => { console.error("group follow-up realtime notification", error); }); if (committed.runId) { await deps.jobs.enqueue(runContinueJob(committed.runId)).catch((error) => { console.error("group follow-up enqueue", error); }); } return { ok: true as const }; }), answer: authed.threads.answer.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); const answered = await deps.events.answerRunInput({ spaceId: context.actor.spaceId, threadId: target.threadId, runId: input.runId, messageId: input.messageId, answeredByUserId: context.actor.userId, answer: input.answer, }); if (!answered) { throw new ORPCError("CONFLICT", { message: "This prompt is no longer awaiting an answer", }); } await deps.jobs.enqueue(runContinueJob(input.runId)).catch((error) => { // The answer and queued run are durable; the reconciler repairs a missed immediate wake. console.error("thread answer enqueue", error); }); return { ok: true as const }; }), markRead: authed.threads.markRead.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); await setThreadUnreadState(deps.prisma, context.actor, target, false); return { ok: true as const }; }), markUnread: authed.threads.markUnread.handler(async ({ context, input }) => { const target = await resolveThreadTarget(deps.prisma, context.actor, input); await setThreadUnreadState(deps.prisma, context.actor, target, true); return { ok: true as const }; }), }, computer: { status: authed.computer.status.handler(async ({ context, input }) => computerStatus(deps, context.actor, input.botId), ), boot: authed.computer.boot.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); if (bot.computer.state === "running" && bot.computer.providerRef) { scheduleComputerSleep(deps.jobs, bot.computer.id); return computerStatus(deps, context.actor, input.botId); } const ctx = computerContext(context.actor, bot.id, "boot"); const manualRunId = `boot:${randomUUID()}`; let lease: ComputerExecutionLease | null; try { lease = await acquireComputerExecutionLease(deps.prisma, { computerId: bot.computer.id, runId: manualRunId, botId: bot.id, }); } catch (error) { if (error instanceof ComputerBusyError) { throw new ORPCError("CONFLICT", { message: "Computer is busy" }); } throw error; } try { await provisionComputer(deps, bot.computer.id, { ...ctx, screenLeaseId: screenLeaseIdForRun(lease, manualRunId), }); scheduleComputerSleep(deps.jobs, bot.computer.id); } finally { await releaseComputerExecutionLease(deps.prisma, lease); } return computerStatus(deps, context.actor, input.botId); }), stop: authed.computer.stop.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); const controlLeaseId = bot.computer.controlLeaseId; const now = new Date(); const claimed = await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, state: { not: "suspending" }, executionLeases: { none: { botId: { not: bot.id }, expiresAt: { gt: now } }, }, }, data: { state: "suspending" }, }); if (claimed.count !== 1) { throw new ORPCError("CONFLICT", { message: "Other Team bots are still using this computer", }); } const otherRun = await deps.prisma.run.findFirst({ where: { botId: { not: bot.id }, status: { in: [...ACTIVE_RUN_STATUSES] }, bot: { computerId: bot.computer.id }, }, select: { id: true }, }); if (otherRun) { await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, state: "suspending" }, data: { state: bot.computer.state }, }); throw new ORPCError("CONFLICT", { message: "Other Team bots are still using this computer", }); } await deps.prisma.computerExecutionLease.deleteMany({ where: { computerId: bot.computer.id, botId: bot.id }, }); try { if (bot.computer.providerRef) { const ctx = computerContext(context.actor, bot.id, "stop"); const ref = toComputerRef(bot.computer); await checkpointAndRecordComputerWorkspace(deps, bot.computer, ref, ctx); await deps.sandbox.stop(ref, ctx); } await deps.prisma.computer.update({ where: { id: bot.computer.id }, data: { state: "stopped", controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: null, }, }); } catch (error) { await deps.prisma.computer .updateMany({ where: { id: bot.computer.id, state: "suspending" }, data: { state: "error" }, }) .catch(() => undefined); throw error; } await deps.jobs.cancel( computerControlExpireJobKey(bot.computer.id, controlLeaseId ?? undefined), ); return computerStatus(deps, context.actor, input.botId); }), recover: authed.computer.recover.handler(async ({ context, input }) => runComputerReplace(deps, context, input.botId, "recover", "recover"), ), reset: authed.computer.reset.handler(async ({ context, input }) => runComputerReplace(deps, context, input.botId, "reset", "reset"), ), update: authed.computer.update.handler(async ({ context, input }) => runComputerReplace(deps, context, input.botId, "update", "update"), ), takeover: authed.computer.takeover.handler(async ({ context, input }) => { let bot = await repos.getBot(context.actor, input.botId); if (!bot.computer?.providerRef || bot.computer.state !== "running") { throw new ORPCError("BAD_REQUEST", { message: "computer must be running" }); } if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id) { await scheduleComputerControlExpiry( deps.jobs, bot.computer.id, bot.computer.controlLeaseId!, bot.computer.controlLeaseExpiresAt!, ); return { leaseId: bot.computer.controlLeaseId!, expiresAt: bot.computer.controlLeaseExpiresAt!.toISOString(), }; } if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId !== bot.id) { const previousBotId = bot.computer.controlBotId!; await deps.sandbox.setScreenControl?.( toComputerRef(bot.computer), false, computerContext(context.actor, previousBotId, "screen.release"), bot.computer.controlLeaseId ?? undefined, ); await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, controlLeaseId: bot.computer.controlLeaseId }, data: { controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: null, }, }); bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); } if (bot.computer.controlLeaseId) { await expireComputerControl(deps, bot.computer.id, bot.computer.controlLeaseId); bot = await repos.getBot(context.actor, input.botId); } if (!bot.computer) throw new IsolationError(); const executionLease = await deps.prisma.computerExecutionLease.findUnique({ where: { computerId_botId: { computerId: bot.computer.id, botId: bot.id } }, }); const executionRun = executionLease ? await deps.prisma.run.findUnique({ where: { id: executionLease.runId }, select: { botId: true, status: true }, }) : null; const waitingForTakeover = executionRun?.botId === bot.id && executionRun.status === "waiting_takeover"; if ( executionBlocksUserTakeover({ hasLease: Boolean(executionLease), leaseExpiresAt: executionLease?.expiresAt, runStatus: executionRun?.status, }) ) { throw new ORPCError("CONFLICT", { message: "Stop the bot first" }); } const executionLeaseActive = Boolean( executionLease && executionLease.expiresAt.getTime() > Date.now(), ); const executionRunActive = Boolean( executionRun && ACTIVE_RUN_STATUSES.some((status) => status === executionRun.status), ); if (executionLease && !executionLeaseActive && !executionRunActive) { await deps.prisma.computerExecutionLease.deleteMany({ where: { id: executionLease.id }, }); } const leaseId = randomUUID(); const expiresAt = new Date(Date.now() + takeoverLeaseMs()); const granted = await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, state: "running", controlHolder: { not: "user" }, controlLeaseId: null, }, data: { controlHolder: "user", controlLeaseId: leaseId, controlLeaseExpiresAt: expiresAt, controlBotId: bot.id, controlRunId: waitingForTakeover ? executionLease?.runId : null, state: "running", }, }); if (granted.count !== 1) { const current = await deps.prisma.computer.findUniqueOrThrow({ where: { id: bot.computer.id }, }); if (!hasActiveComputerControl(current)) throw new ORPCError("CONFLICT"); await scheduleComputerControlExpiry( deps.jobs, current.id, current.controlLeaseId!, current.controlLeaseExpiresAt!, ); return { leaseId: current.controlLeaseId!, expiresAt: current.controlLeaseExpiresAt!.toISOString(), }; } try { await scheduleComputerControlExpiry(deps.jobs, bot.computer.id, leaseId, expiresAt); } catch (error) { await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, controlLeaseId: leaseId }, data: { controlHolder: "none", controlLeaseId: null, controlLeaseExpiresAt: null, controlBotId: null, controlRunId: null, }, }); throw error; } if (bot.thread) { await deps.events.append({ spaceId: context.actor.spaceId, threadId: bot.thread.id, botId: bot.id, type: "computer.takeover.granted", payload: { leaseId, takeoverRequested: waitingForTakeover }, }); } scheduleComputerSleep(deps.jobs, bot.computer.id); return { leaseId, expiresAt: expiresAt.toISOString() }; }), release: authed.computer.release.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); const controlBotId = bot.computer.controlBotId; const controlLeaseId = bot.computer.controlLeaseId; if ( !hasActiveComputerControl(bot.computer) || bot.computer.controlHolder !== "user" || !controlBotId || !controlLeaseId || controlBotId !== bot.id ) { return { ok: true as const }; } if (bot.computer.providerRef) { await deps.sandbox.setScreenControl?.( toComputerRef(bot.computer), false, computerContext(context.actor, controlBotId, "screen.release"), controlLeaseId, ); } const released = await deps.events.finalizeComputerControlRelease({ spaceId: context.actor.spaceId, computerId: bot.computer.id, botId: controlBotId, runId: bot.computer.controlRunId, leaseId: controlLeaseId, holder: "bot", reason: input.reason ?? "released", }); if (!released) return { ok: true as const }; // The lease-specific key makes this cancellation safe after a replacement takeover. await deps.jobs .cancel(computerControlExpireJobKey(bot.computer.id, controlLeaseId)) .catch((error) => { // The expired job is harmless after the lease is cleared, so do not report a // failed release after the transaction has committed. console.error("computer control expiry cancellation", error); }); await enqueueTakeoverContinuation(deps.jobs, released.runId); scheduleComputerSleep(deps.jobs, bot.computer.id); return { ok: true as const }; }), input: authed.computer.input.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); const computer = bot.computer; if (!computer || !hasActiveComputerControl(computer) || computer.controlBotId !== bot.id) { await expireStaleComputerControl(deps, computer); throw new ORPCError("FORBIDDEN"); } if (!computer.providerRef) return { ok: true as const }; const mapped = input.kind === "key" ? { kind: "key" as const, key: String(input.payload.key ?? "") } : input.kind === "clipboard" ? { kind: "clipboard" as const, text: String(input.payload.text ?? "") } : input.kind === "scroll" ? { kind: "scroll" as const, direction: input.payload.direction === "up" ? ("up" as const) : ("down" as const), amount: Number(input.payload.amount ?? 3), } : { kind: "pointer" as const, x: Number(input.payload.x ?? 0), y: Number(input.payload.y ?? 0), button: (input.payload.button as "left" | "right" | undefined) ?? "left", type: (input.payload.type as "move" | "down" | "up" | "click" | undefined) ?? "click", }; const outcome = await taughtSkills.recordInput(context.actor, bot.id, mapped); if (outcome === "stale") return { ok: true as const }; if (outcome !== "recorded") { await applyTeachingDesktopInput( deps.sandbox, computer, mapped, computerContext(context.actor, bot.id, "input"), ); } await deps.prisma.computer.updateMany({ where: { id: computer.id, state: "running" }, data: { updatedAt: new Date() }, }); scheduleComputerSleep(deps.jobs, computer.id); return { ok: true as const }; }), files: authed.computer.files.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); const computer = bot.computer; const computerMode = parseComputerMode(computer.scope); const ctx = computerContext(context.actor, bot.id, "files"); const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); let entries: Awaited>; if (computer.state === "running" && computer.providerRef) { await deps.prisma.computer.updateMany({ where: { id: computer.id, state: "running" }, data: { updatedAt: new Date() }, }); scheduleComputerSleep(deps.jobs, computer.id); entries = await deps.sandbox.listFiles(toComputerRef(computer), storedPath, ctx); } else { entries = await deps.home.list(computer.homeKey, storedPath, ctx); } return entries.map((entry) => ({ ...entry, path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path), })); }), readFile: authed.computer.readFile.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.computer) throw new IsolationError(); const computerMode = parseComputerMode(bot.computer.scope); const ctx = computerContext(context.actor, bot.id, "read"); const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); let content: string; if (bot.computer.state === "running" && bot.computer.providerRef) { await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, state: "running" }, data: { updatedAt: new Date() }, }); scheduleComputerSleep(deps.jobs, bot.computer.id); const bytes = await deps.sandbox.readFile(toComputerRef(bot.computer), storedPath, ctx, { maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, }); content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } else { try { content = await deps.home.readFile(bot.computer.homeKey, storedPath, ctx, { maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, }); } catch (error) { if (error instanceof Error && error.message.startsWith("agent home file exceeds ")) { throw new ORPCError("BAD_REQUEST", { message: "file is too large to preview" }); } throw error; } } return { path: input.path, content }; }), screenUrl: authed.computer.screenUrl.handler(async ({ context, input }) => { let bot = await repos.getBot(context.actor, input.botId); if (await expireStaleComputerControl(deps, bot.computer)) { bot = await repos.getBot(context.actor, input.botId); } if ( !bot.computer?.providerRef || (bot.computer.state !== "running" && bot.computer.state !== "booting") ) { return { url: null }; } const computer = bot.computer; const session = await deps.sandbox .connectScreen( toComputerRef(computer), { view: "stream", interactive: hasActiveComputerControl(computer) && computer.controlBotId === bot.id, controlToken: computer.controlBotId === bot.id ? (computer.controlLeaseId ?? undefined) : undefined, }, await computerScreenContext(deps.prisma, context.actor, computer.id, bot.id, "screen"), ) .catch(async (error: unknown) => { if (!isSandboxGoneError(error)) throw error; // The provider killed this sandbox (idle timeout) while the row still says // running. Clear the dead ref so the UI offers a boot instead of 500ing. // Leave any active control lease alone — expireComputerControl owns that // release (provider screen-control, events, takeover continuation). console.error(`computer ${computer.id} sandbox ${computer.providerRef} is gone`, error); await deps.prisma.computer.updateMany({ where: { id: computer.id, providerRef: computer.providerRef }, data: { state: "stopped", providerRef: null }, }); return null; }); if (!session?.url) return { url: null }; scheduleComputerSleep(deps.jobs, bot.computer.id); const viewUrl = withViewOnly( session.url, !(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id), ); return { url: addScreenProxyCapability( viewUrl, deps.env.screenProxySecret, deps.env.webOrigin, undefined, { proxyExternal: bot.computer.kind === "box" }, ), }; }), heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (bot.computer?.state === "running" && bot.computer.providerRef) { await deps.prisma.computer.updateMany({ where: { id: bot.computer.id, state: "running" }, data: { updatedAt: new Date() }, }); await touchRunningComputer( { sandbox: deps.sandbox, jobs: deps.jobs }, { id: bot.computer.id, homeKey: bot.computer.homeKey, providerRef: bot.computer.providerRef, kind: bot.computer.kind, }, ).catch(() => undefined); } return { ok: true as const }; }), }, memory: { list: authed.memory.list.handler(async ({ context, input }) => { const docs = await deps.prisma.memoryDocument.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId, ...(input.botId ? { botId: input.botId } : {}), ...(input.scope ? { scope: input.scope } : {}), }, }); return docs.map((doc) => ({ id: doc.id, scope: doc.scope as "bot" | "user", botId: doc.botId, path: doc.path, content: doc.content, revision: doc.revision, updatedAt: doc.updatedAt.toISOString(), })); }), update: authed.memory.update.handler(async ({ context, input }) => { const doc = await deps.prisma.memoryDocument.findFirst({ where: { id: input.documentId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!doc) throw new IsolationError(); const updated = await deps.memory.commit( { scope: doc.scope as "bot" | "user", botId: doc.botId ?? undefined, path: doc.path, content: input.content, }, { operationId: "mem", traceId: "mem", spaceId: context.actor.spaceId, userId: context.actor.userId, signal: new AbortController().signal, }, ); return { id: updated.id, scope: doc.scope as "bot" | "user", botId: doc.botId, path: updated.path, content: updated.content, revision: updated.revision, updatedAt: new Date().toISOString(), }; }), exportMarkdown: authed.memory.exportMarkdown.handler(async ({ context, input }) => { const docs = await deps.prisma.memoryDocument.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId, ...(input.botId ? { botId: input.botId } : {}), }, }); return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n"); }), providerConfig: authed.memory.providerConfig.handler(async ({ context }) => { const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId); return config ? serializeSpaceMemoryConfig(config) : null; }), connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) => persistMemoryProviderConfig(deps, context.actor, input), ), setDefaultScope: authed.memory.setDefaultScope.handler(async ({ context, input }) => updateMemoryProviderDefaultScope(deps, context.actor, input.defaultMemoryScope), ), disconnectProvider: authed.memory.disconnectProvider.handler(async ({ context }) => { await requireSpaceOwner(deps.prisma, context.actor); await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { const existing = await findSpaceMemoryConfig(tx, context.actor.spaceId); if (!existing) return; await tx.spaceMemoryConfig.delete({ where: { id: existing.id } }); await tx.secret.deleteMany({ where: { id: existing.secretId } }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); return { ok: true as const }; }), }, routines: { list: authed.routines.list.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); return listRoutinesDto(deps, context.actor, input.botId); }), create: authed.routines.create.handler(async ({ context, input }) => { if (hasMixedOneShotSchedule(input.crons)) { throw new ORPCError("BAD_REQUEST", { message: "A one-time schedule can't be combined with other schedules.", }); } if (input.active && isOneShotRoutineCrons(input.crons)) { throw new ORPCError("BAD_REQUEST", { message: "One-shot schedules must be created from chat.", }); } const bot = await repos.getBot(context.actor, input.botId); // Validate every recurring cron even when inactive; @once and webhook-only have no next date. let nextRunAt: Date | null = null; if (input.crons.length > 0 && !isOneShotRoutineCrons(input.crons)) { const computedNextRunAt = nextRoutineDate(input.crons, input.timezone); nextRunAt = input.active ? computedNextRunAt : null; } const row = await deps.prisma.routine.create({ data: { spaceId: context.actor.spaceId, botId: input.botId, userId: context.actor.userId, name: input.name, prompt: input.prompt, crons: input.crons, timezone: input.timezone, notify: input.notify, active: input.active, webhookEnabled: input.webhookEnabled, nextRunAt, }, }); if (bot.thread) { await deps.events.append({ spaceId: context.actor.spaceId, threadId: bot.thread.id, botId: bot.id, type: "routine.created", payload: { name: row.name }, }); } if (row.active && row.nextRunAt) { await deps.jobs.enqueue(routineWakeupJob(row.id, row.nextRunAt)); } return mapRoutine(row); }), update: authed.routines.update.handler(async ({ context, input }) => { const existing = await deps.prisma.routine.findFirst({ where: { id: input.routineId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) throw new IsolationError(); const active = input.active ?? existing.active; const crons = input.crons ?? existing.crons; const timezone = input.timezone ?? existing.timezone; const webhookEnabled = input.webhookEnabled ?? existing.webhookEnabled; if (crons.length === 0 && !webhookEnabled) { throw new ORPCError("BAD_REQUEST", { message: "Add a schedule or webhook trigger", }); } if (hasMixedOneShotSchedule(crons)) { throw new ORPCError("BAD_REQUEST", { message: "A one-time schedule can't be combined with other schedules.", }); } if (active && isOneShotRoutineCrons(crons)) { if (!isOneShotRoutineCrons(existing.crons)) { throw new ORPCError("BAD_REQUEST", { message: "One-shot schedules must be created from chat.", }); } if (!existing.nextRunAt && existing.lastRunAt) { throw new ORPCError("BAD_REQUEST", { message: "This one-shot already ran.", }); } } const scheduleChanged = (!existing.active && active) || (input.crons !== undefined && JSON.stringify(input.crons) !== JSON.stringify(existing.crons)) || (input.timezone !== undefined && input.timezone !== existing.timezone); const recalculatedNextRunAt = crons.length > 0 && !isOneShotRoutineCrons(crons) && (scheduleChanged || (active && !existing.nextRunAt)) ? nextRoutineDate(crons, timezone) : null; let armedOneShotAt: Date | null = null; if (active && isOneShotRoutineCrons(crons) && !existing.nextRunAt && !existing.lastRunAt) { if (!input.runAt) { throw new ORPCError("BAD_REQUEST", { message: "Add a run time for this one-shot.", }); } const parsed = new Date(input.runAt); if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= Date.now()) { throw new ORPCError("BAD_REQUEST", { message: "Run time must be in the future.", }); } armedOneShotAt = parsed; } else if (input.runAt !== undefined) { throw new ORPCError("BAD_REQUEST", { message: "A run time is only for one-shots that have not run yet.", }); } const nextRunAt = !active ? null : crons.length === 0 ? null : isOneShotRoutineCrons(crons) ? (armedOneShotAt ?? existing.nextRunAt) : (recalculatedNextRunAt ?? existing.nextRunAt); const row = await deps.prisma.routine.update({ where: { id: existing.id }, data: { name: input.name, prompt: input.prompt, crons: input.crons, timezone: input.timezone, active: input.active, notify: input.notify, webhookEnabled: input.webhookEnabled, nextRunAt, }, }); const bot = await repos.getBot(context.actor, row.botId); if (bot.thread) { await deps.events.append({ spaceId: context.actor.spaceId, threadId: bot.thread.id, botId: bot.id, type: "routine.updated", payload: { routineId: row.id, active: row.active }, }); } const scheduleNeedsSync = existing.active !== row.active || scheduleChanged || (!existing.nextRunAt && !!row.nextRunAt); if (scheduleNeedsSync) { if (row.active && row.nextRunAt) { await deps.jobs.enqueue(routineWakeupJob(row.id, row.nextRunAt)); } else { await deps.jobs.cancel(routineJobKey(row.id)); } } return mapRoutine(row); }), remove: authed.routines.remove.handler(async ({ context, input }) => { const existing = await deps.prisma.routine.findFirst({ where: { id: input.routineId, spaceId: context.actor.spaceId }, }); if (!existing) throw new IsolationError(); await deps.prisma.routine.delete({ where: { id: existing.id } }); await deps.jobs.cancel(routineJobKey(existing.id)); return { ok: true as const }; }), testRun: authed.routines.testRun.handler(async ({ context, input }) => { const routine = await deps.prisma.routine.findFirst({ where: { id: input.routineId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!routine) throw new IsolationError(); const bot = await repos.getBot(context.actor, routine.botId); if (!bot.thread) throw new IsolationError(); const threadId = bot.thread.id; const nonce = input.clientNonce ? `routine-test:${input.clientNonce}` : undefined; if (nonce) { const existing = await deps.prisma.run.findFirst({ where: { threadId, clientNonce: nonce }, select: { id: true }, }); if (existing) return { runId: existing.id }; } const skillRecords = await agentSkills.listWithContent(context.actor); const prompt = expandSkillReferencesInPrompt(routine.prompt, skillRecords); let run: { id: string }; try { // Task + run must commit together so a nonce collision cannot leave an orphan queued Task. run = await deps.prisma.$transaction(async (tx) => { if (nonce) { const existing = await tx.run.findFirst({ where: { threadId, clientNonce: nonce }, select: { id: true }, }); if (existing) return existing; } const task = await tx.task.create({ data: { spaceId: context.actor.spaceId, botId: bot.id, threadId, userId: context.actor.userId, prompt, status: "queued", }, }); return tx.run.create({ data: { spaceId: context.actor.spaceId, botId: bot.id, threadId, taskId: task.id, userId: context.actor.userId, status: "queued", trigger: "routine", routineId: routine.id, clientNonce: nonce, }, select: { id: true }, }); }); } catch (error) { if (nonce) { const existing = await deps.prisma.run.findFirst({ where: { threadId, clientNonce: nonce }, select: { id: true }, }); if (existing) return { runId: existing.id }; } throw error; } // Keep enqueue outside the nonce-collision catch. The queued run is durable; // log enqueue failures and still return success — the reconciler repairs a missed wake. await deps.jobs.enqueue(runContinueJob(run.id)).catch((error) => { console.error("routine testRun enqueue", error); }); return { runId: run.id }; }), }, scratchpad: { list: authed.scratchpad.list.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); return listScratchpadItems( { prisma: deps.prisma }, { spaceId: context.actor.spaceId, botId: input.botId, status: input.status, includeDone: input.includeDone ?? false, }, ); }), create: authed.scratchpad.create.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); const row = await deps.prisma.scratchpadItem.create({ data: { spaceId: context.actor.spaceId, botId: input.botId, userId: context.actor.userId, title: input.title.trim(), status: input.status, notes: input.notes.trim(), }, }); return mapScratchpadItem(row); }), update: authed.scratchpad.update.handler(async ({ context, input }) => { const existing = await deps.prisma.scratchpadItem.findFirst({ where: { id: input.itemId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) throw new IsolationError(); if (input.status !== undefined && !isScratchpadStatus(input.status)) { throw new ORPCError("BAD_REQUEST", { message: "Invalid scratchpad status." }); } const row = await deps.prisma.scratchpadItem.update({ where: { id: existing.id }, data: { ...(input.title !== undefined ? { title: input.title.trim() } : {}), ...(input.status !== undefined ? { status: input.status } : {}), ...(input.notes !== undefined ? { notes: input.notes.trim() } : {}), }, }); return mapScratchpadItem(row); }), remove: authed.scratchpad.remove.handler(async ({ context, input }) => { const existing = await deps.prisma.scratchpadItem.findFirst({ where: { id: input.itemId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) throw new IsolationError(); await deps.prisma.scratchpadItem.delete({ where: { id: existing.id } }); return { ok: true as const }; }), }, skills: { list: authed.skills.list.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); return taughtSkills.list(context.actor, input.botId); }), get: authed.skills.get.handler(async ({ context, input }) => taughtSkills.get(context.actor, input.skillId), ), start: authed.skills.start.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); return taughtSkills.start(context.actor, input.botId, input.goal); }), appendEvent: authed.skills.appendEvent.handler(async ({ context, input }) => taughtSkills.appendEvent(context.actor, input.skillId, input.event), ), snapshot: authed.skills.snapshot.handler(async ({ context, input }) => taughtSkills.snapshot(context.actor, input.skillId), ), stop: authed.skills.stop.handler(async ({ context, input }) => taughtSkills.stop(context.actor, input.skillId), ), updateDraft: authed.skills.updateDraft.handler(async ({ context, input }) => taughtSkills.updateDraft(context.actor, input.skillId, { name: input.name, playbook: input.playbook, }), ), save: authed.skills.save.handler(async ({ context, input }) => taughtSkills.save(context.actor, input.skillId, input.name), ), testRun: authed.skills.testRun.handler(async ({ context, input }) => taughtSkills.testRun(context.actor, input.skillId, input.prompt), ), remove: authed.skills.remove.handler(async ({ context, input }) => taughtSkills.remove(context.actor, input.skillId), ), }, agentSkills: { list: authed.agentSkills.list.handler(async ({ context }) => agentSkills.list(context.actor)), get: authed.agentSkills.get.handler(async ({ context, input }) => agentSkills.get(context.actor, input), ), create: authed.agentSkills.create.handler(async ({ context, input }) => agentSkills.create(context.actor, input), ), update: authed.agentSkills.update.handler(async ({ context, input }) => agentSkills.update(context.actor, input), ), remove: authed.agentSkills.remove.handler(async ({ context, input }) => agentSkills.remove(context.actor, input.skillId), ), }, capabilities: { list: authed.capabilities.list.handler(async ({ context }) => { const rows = await deps.prisma.capabilityInstall.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId }, }); return rows.map((row) => ({ id: row.id, kind: row.kind as "skill" | "plugin" | "mcp" | "api" | "connection", name: row.name, source: row.source, version: row.version, digest: row.digest, secretConfigured: Boolean(row.secretId), config: row.config as Record, createdAt: row.createdAt.toISOString(), })); }), install: authed.capabilities.install.handler(async ({ context, input }) => { let source = input.source.trim(); let config = input.config; const credential = input.credential?.trim() || undefined; if ( credential && credential.length >= 8 && (source.includes(credential) || containsSecret(config, [credential])) ) { throw new ORPCError("BAD_REQUEST", { message: "Put credentials only in the encrypted credential field", }); } if (JSON.stringify(config).length > 2_000_000) { throw new ORPCError("BAD_REQUEST", { message: "Capability configuration is too large" }); } if (credential && input.kind !== "mcp" && input.kind !== "api") { throw new ORPCError("BAD_REQUEST", { message: "Credentials are only accepted for MCP and API tool sources", }); } try { if (input.kind === "mcp") { if (config.preset === "treg") { source = "https://treg.to/mcp/"; config = { ...config, preset: "treg", auth: { type: "bearer" } }; } const verified = await verifyMcpInstall({ source, config, credential, signal: context.signal, remote: deps.remoteConnectors, }); config = verified.config; } if (input.kind === "api") { const prepared = await prepareApiInstall({ source, config, credential, signal: context.signal, remote: deps.remoteConnectors, }); source = prepared.source; config = prepared.config; } } catch (error) { const message = sanitizeComposioError(error); throw new ORPCError("BAD_REQUEST", { message: credential ? message.split(credential).join("[redacted]") : message, }); } const stored = credential ? await deps.secrets.put(credential, { operationId: "capabilities.install", traceId: "capabilities.install", spaceId: context.actor.spaceId, userId: context.actor.userId, signal: context.signal ?? new AbortController().signal, }) : undefined; const digest = `sha256:${createHash("sha256") .update(JSON.stringify({ kind: input.kind, source, config })) .digest("hex")}`; const row = await deps.prisma.$transaction(async (tx) => { if (stored) { await tx.secret.create({ data: { id: stored.id, spaceId: context.actor.spaceId, userId: context.actor.userId, kind: "connector", ciphertext: stored.ciphertext, }, }); } return tx.capabilityInstall.create({ data: { spaceId: context.actor.spaceId, userId: context.actor.userId, kind: input.kind, name: input.name.trim(), source, secretId: stored?.id, config: config as Prisma.InputJsonValue, digest, version: "1.0.0", }, }); }); return { id: row.id, kind: row.kind as "skill" | "plugin" | "mcp" | "api" | "connection", name: row.name, source: row.source, version: row.version, digest: row.digest, secretConfigured: Boolean(row.secretId), config: row.config as Record, createdAt: row.createdAt.toISOString(), }; }), remove: authed.capabilities.remove.handler(async ({ context, input }) => { await deps.prisma.$transaction(async (tx) => { const existing = await tx.capabilityInstall.findFirst({ where: { id: input.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) return; await tx.capabilityInstall.delete({ where: { id: existing.id } }); if (existing.secretId) { const shared = await tx.capabilityInstall.count({ where: { secretId: existing.secretId }, }); if (shared === 0) { await tx.secret.deleteMany({ where: { id: existing.secretId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); } } }); return { ok: true as const }; }), }, mcp: { servers: { list: authed.mcp.servers.list.handler(async ({ context }) => { const rows = await deps.prisma.mcpServer.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId }, orderBy: [{ name: "asc" }, { createdAt: "asc" }], }); const secretIds = rows.flatMap((row) => (row.secretId ? [row.secretId] : [])); const secrets = secretIds.length ? await deps.prisma.secret.findMany({ where: { id: { in: secretIds }, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true, ciphertext: true }, }) : []; const ciphertextById = new Map(secrets.map((secret) => [secret.id, secret.ciphertext])); return rows.map((row) => mcpServerDto( row, mcpOAuth.statusForCiphertext( row.secretId ? ciphertextById.get(row.secretId) : undefined, row.secretId ?? undefined, ), ), ); }), create: authed.mcp.servers.create.handler(async ({ context, input }) => { const secretPayload = buildMcpCredentialBlob(input); const stored = secretPayload ? await deps.secrets.put( secretPayload, computerContext(context.actor, "mcp", "mcp.create"), ) : null; const row = await deps.prisma.$transaction(async (tx) => { if (stored) { await tx.secret.create({ data: { id: stored.id, userId: context.actor.userId, spaceId: context.actor.spaceId, kind: "mcp", ciphertext: stored.ciphertext, }, }); } return tx.mcpServer.create({ data: { spaceId: context.actor.spaceId, userId: context.actor.userId, slug: input.slug, name: input.name, description: input.description, transport: input.transport, endpoint: "endpoint" in input ? input.endpoint : null, command: "command" in input ? input.command : null, args: ("args" in input ? input.args : []) as Prisma.InputJsonValue, env: ("env" in input ? Object.fromEntries(Object.keys(input.env).map((key) => [key, true])) : {}) as Prisma.InputJsonValue, headers: ("headers" in input ? Object.fromEntries(Object.keys(input.headers).map((key) => [key, true])) : {}) as Prisma.InputJsonValue, secretId: stored?.id, enabled: input.enabled, }, }); }); return mcpServerDto(row, await mcpOAuth.statusFor(row, context.actor)); }), update: authed.mcp.servers.update.handler(async ({ context, input }) => { const config = input.config; const row = await deps.prisma.$transaction(async (tx) => { // Share the OAuth broker's per-server lock so a stale authorization // snapshot cannot overwrite a simultaneous credential edit. await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('mcp-oauth-material'), hashtext(${input.id}))`; const existing = await tx.mcpServer.findFirst({ where: { id: input.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) throw new IsolationError(); const existingSecret = existing.secretId ? await tx.secret.findFirst({ where: { id: existing.secretId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }) : null; let existingMaterial: Record = {}; if (existingSecret) { try { const value = JSON.parse( deps.secrets.load(existingSecret.ciphertext, existingSecret.id), ); if (value && typeof value === "object" && !Array.isArray(value)) existingMaterial = value as Record; } catch { /* Existing malformed secrets are replaced only when new credentials are supplied. */ } } const nextEndpoint = "endpoint" in config ? config.endpoint : null; const update = buildMcpUpdateMaterial(existingMaterial, config, { clearOAuth: existing.endpoint !== nextEndpoint, }); const stored = update.action === "store" && Object.keys(update.material).length > 0 ? await deps.secrets.put( JSON.stringify(update.material), computerContext(context.actor, "mcp", "mcp.update"), ) : null; const clearing = update.action === "store" && Object.keys(update.material).length === 0; const updated = await tx.mcpServer.update({ where: { id: existing.id }, data: { slug: config.slug, name: config.name, description: config.description, transport: config.transport, endpoint: nextEndpoint, command: "command" in config ? config.command : null, args: ("args" in config ? config.args : []) as Prisma.InputJsonValue, env: ("env" in config ? Object.fromEntries(Object.keys(config.env).map((key) => [key, true])) : {}) as Prisma.InputJsonValue, headers: ("headers" in config ? Object.fromEntries(Object.keys(config.headers).map((key) => [key, true])) : {}) as Prisma.InputJsonValue, enabled: config.enabled, revision: { increment: 1 }, ...(stored ? { secretId: stored.id } : clearing ? { secretId: null } : {}), }, }); if (stored) { await tx.secret.create({ data: { id: stored.id, userId: context.actor.userId, spaceId: context.actor.spaceId, kind: "mcp", ciphertext: stored.ciphertext, }, }); if (existing.secretId) await tx.secret.deleteMany({ where: { id: existing.secretId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); } else if (clearing && existing.secretId) { await tx.secret.deleteMany({ where: { id: existing.secretId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); } return updated; }); return mcpServerDto(row, await mcpOAuth.statusFor(row, context.actor)); }), remove: authed.mcp.servers.remove.handler(async ({ context, input }) => { const server = await deps.prisma.mcpServer.findFirst({ where: { id: input.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true, secretId: true }, }); if (!server) throw new IsolationError(); // Assignments cascade; the encrypted credential must go with the server. await deps.prisma.$transaction([ deps.prisma.mcpServer.delete({ where: { id: server.id } }), ...(server.secretId ? [ deps.prisma.secret.deleteMany({ where: { id: server.secretId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }), ] : []), ]); return { ok: true as const }; }), }, assignments: { all: authed.mcp.assignments.all.handler(async ({ context }) => { const rows = await deps.prisma.botMcpServer.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId, bot: { archivedAt: null }, }, orderBy: { createdAt: "asc" }, }); return rows.map(mcpAssignmentDto); }), list: authed.mcp.assignments.list.handler(async ({ context, input }) => { const bot = await deps.prisma.bot.findFirst({ where: { id: input.botId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }); if (!bot) throw new IsolationError(); const rows = await deps.prisma.botMcpServer.findMany({ where: { botId: bot.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, orderBy: { createdAt: "asc" }, }); return rows.map(mcpAssignmentDto); }), approve: authed.mcp.assignments.approve.handler(async ({ context, input }) => { const row = await deps.prisma.$transaction(async (tx) => { const [bot, server] = await Promise.all([ tx.bot.findFirst({ where: { id: input.botId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }), tx.mcpServer.findFirst({ where: { id: input.serverId, spaceId: context.actor.spaceId, userId: context.actor.userId, enabled: true, }, select: { id: true }, }), ]); if (!bot || !server) throw new IsolationError(); return tx.botMcpServer.upsert({ where: { botId_serverId: { botId: bot.id, serverId: server.id } }, create: { spaceId: context.actor.spaceId, userId: context.actor.userId, botId: bot.id, serverId: server.id, allowAllTools: true, allowedTools: [], }, update: {}, }); }); return mcpAssignmentDto(row); }), replace: authed.mcp.assignments.replace.handler(async ({ context, input }) => { const result = await deps.prisma.$transaction(async (tx) => { const bot = await tx.bot.findFirst({ where: { id: input.botId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }); if (!bot) throw new IsolationError(); const servers = await tx.mcpServer.findMany({ where: { id: { in: input.assignments.map((assignment) => assignment.serverId) }, spaceId: context.actor.spaceId, userId: context.actor.userId, }, select: { id: true }, }); if (servers.length !== input.assignments.length) throw new IsolationError(); await tx.botMcpServer.deleteMany({ where: { botId: bot.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (input.assignments.length) await tx.botMcpServer.createMany({ data: input.assignments.map((assignment) => ({ spaceId: context.actor.spaceId, userId: context.actor.userId, botId: bot.id, serverId: assignment.serverId, allowAllTools: assignment.allowAllTools, allowedTools: assignment.allowedTools as Prisma.InputJsonValue, })), }); return tx.botMcpServer.findMany({ where: { botId: bot.id, spaceId: context.actor.spaceId, userId: context.actor.userId, }, orderBy: { createdAt: "asc" }, }); }); return result.map(mcpAssignmentDto); }), }, oauth: { begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => { try { const expectedRedirect = new URL("/mcp/oauth/callback", deps.env.webOrigin).toString(); if (new URL(input.redirectUri).toString() !== expectedRedirect) { throw new Error("MCP OAuth redirect URI is not allowed"); } return await mcpOAuth.begin({ ...input, spaceId: context.actor.spaceId, userId: context.actor.userId, }); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Could not start MCP OAuth", }); } }), complete: authed.mcp.oauth.complete.handler(async ({ context, input }) => { try { await mcpOAuth.complete({ ...input, spaceId: context.actor.spaceId, userId: context.actor.userId, }); return { ok: true as const }; } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Could not complete MCP OAuth", }); } }), disconnect: authed.mcp.oauth.disconnect.handler(async ({ context, input }) => { await mcpOAuth.disconnect({ ...input, spaceId: context.actor.spaceId, userId: context.actor.userId, }); return { ok: true as const }; }), }, }, onboarding: { start: authed.onboarding.start.handler(async ({ context, input }) => { await startOnboarding( { prisma: deps.prisma, events: deps.events, composio: deps.composio }, context.actor, input.botId, ); return { ok: true as const }; }), choose: authed.onboarding.choose.handler(async ({ context, input }) => { await chooseFocus( { prisma: deps.prisma, events: deps.events, composio: deps.composio }, context.actor, input.botId, input.optionId, ); return { ok: true as const }; }), appConnected: authed.onboarding.appConnected.handler(async ({ context, input }) => { await markAppConnected( { prisma: deps.prisma, events: deps.events, composio: deps.composio }, context.actor, input.botId, input.provider, ); return { ok: true as const }; }), }, connections: { catalog: authed.connections.catalog.handler(async ({ context, input }) => { const adapterContext = connectionContext( context.actor, "connections.catalog", context.signal, ); const providers = input.connectorId ? [deps.connectors.managed(input.connectorId)].filter( (provider): provider is NonNullable => Boolean(provider), ) : deps.connectors.managedProviders(); const catalogs = await Promise.all( providers.map(async (provider): Promise => { try { const items = await provider.catalog(adapterContext, input.query); const nowConnected = items.filter((item) => item.connected).map((item) => item.slug); if (nowConnected.length > 0) { await reconcilePendingConnections( deps.prisma, context.actor, provider.describe().id, nowConnected, ).catch((error) => { console.error( `${provider.describe().id} pending-connection reconciliation failed`, error, ); }); } return items; } catch { return []; } }), ); return catalogs.flat(); }), list: authed.connections.list.handler(async ({ context }) => { const rows = await deps.prisma.connection.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId }, }); return rows.map((row) => ({ id: row.id, connectorId: row.connectorId, provider: row.provider, displayName: row.displayName, status: row.status as "pending" | "connected" | "revoked" | "error", capabilities: [], createdAt: row.createdAt.toISOString(), })); }), begin: authed.connections.begin.handler(async ({ context, input }) => { const connector = deps.connectors.managed(input.connectorId); if (!connector) { throw new ORPCError("BAD_REQUEST", { message: `Connector ${input.connectorId} is not configured`, }); } const row = await deps.prisma.connection.create({ data: { spaceId: context.actor.spaceId, userId: context.actor.userId, connectorId: input.connectorId, provider: input.provider, displayName: input.displayName, status: "pending", }, }); try { const auth = await connector.begin( { provider: input.provider, redirectUrl: `${deps.env.webOrigin}/app` }, connectionContext(context.actor, "connections.begin", context.signal), ); await deps.prisma.connection.update({ where: { id: row.id }, data: { status: auth.authorizationUrl ? "pending" : "connected", providerRef: auth.state || null, metadata: { state: auth.state }, }, }); return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; } catch (error) { await deps.prisma.connection.update({ where: { id: row.id }, data: { status: "error" }, }); throw new ORPCError("BAD_REQUEST", { message: sanitizeComposioError(error) }); } }), complete: authed.connections.complete.handler(async ({ context, input }) => { const existing = await deps.prisma.connection.findFirst({ where: { id: input.connectionId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (!existing) throw new IsolationError(); const connector = deps.connectors.managed(existing.connectorId); if (!connector) { throw new ORPCError("BAD_REQUEST", { message: `Connector ${existing.connectorId} is not configured`, }); } let row = existing; if (existing.status !== "connected") { if (input.code) { const state = existing.providerRef ?? existing.provider; try { await connector.complete( { state, code: input.code }, connectionContext(context.actor, "connections.complete", context.signal), ); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: sanitizeComposioError(error) }); } } const ready = await connector.connectionReady( connectionContext(context.actor, "connections.complete", context.signal), existing.provider, ); if (ready) { row = await deps.prisma.connection.update({ where: { id: existing.id }, data: { status: "connected" }, }); } } return { id: row.id, connectorId: row.connectorId, provider: row.provider, displayName: row.displayName, status: row.status as "pending" | "connected" | "revoked" | "error", capabilities: [], createdAt: row.createdAt.toISOString(), }; }), revoke: authed.connections.revoke.handler(async ({ context, input }) => { const row = await deps.prisma.connection.findFirst({ where: { id: input.connectionId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); if (row) { const connector = deps.connectors.managed(row.connectorId); if (!connector) { throw new ORPCError("BAD_REQUEST", { message: `Connector ${row.connectorId} is not configured`, }); } try { await connector.revoke( row.provider, connectionContext(context.actor, "connections.revoke", context.signal), ); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: sanitizeComposioError(error) }); } } await deps.prisma.connection.updateMany({ where: { id: input.connectionId, spaceId: context.actor.spaceId, userId: context.actor.userId, }, data: { status: "revoked" }, }); return { ok: true as const }; }), }, messaging: { status: authed.messaging.status.handler(async ({ context }) => { const identities = await deps.prisma.messagingIdentity.findMany({ where: { userId: context.actor.userId }, orderBy: { createdAt: "asc" }, }); return { enabled: deps.messaging?.enabled ?? false, providers: deps.messaging?.providers ?? [], openSignup: deps.messaging?.openSignup ?? false, identities: await Promise.all( identities.map((identity) => messagingIdentityDto(deps.prisma, identity)), ), }; }), link: { start: authed.messaging.link.start.handler(async ({ context, input }) => { const bot = await deps.prisma.bot.findFirst({ where: { id: input.botId, spaceId: context.actor.spaceId, userId: context.actor.userId, archivedAt: null, }, select: { id: true }, }); if (!bot) throw new ORPCError("NOT_FOUND"); // One chat identity per bot: delivery mirrors a bot's replies to // exactly one conversation. const linked = await deps.prisma.messagingIdentity.findUnique({ where: { botId: bot.id }, select: { id: true }, }); if (linked) { throw new ORPCError("BAD_REQUEST", { message: "That bot is already linked to a chat app; unlink it first.", }); } const issued = await issueMessagingLinkCode(deps.prisma, { userId: context.actor.userId, spaceId: context.actor.spaceId, botId: bot.id, }); return { code: formatMessagingLinkCode(issued.code), expiresAt: issued.expiresAt.toISOString(), }; }), }, identities: { setBot: authed.messaging.identities.setBot.handler(async ({ context, input }) => { const identity = await deps.prisma.messagingIdentity.findFirst({ where: { id: input.identityId, userId: context.actor.userId }, }); if (!identity) throw new ORPCError("NOT_FOUND"); const bot = await deps.prisma.bot.findFirst({ where: { id: input.botId, spaceId: context.actor.spaceId, userId: context.actor.userId, archivedAt: null, }, select: { id: true }, }); if (!bot) throw new ORPCError("NOT_FOUND"); try { const updated = await deps.prisma.messagingIdentity.update({ where: { id: identity.id }, // The identity must live in the bot's space: runs resolve // credentials, memory, and approval rules from run.spaceId. data: { botId: bot.id, spaceId: context.actor.spaceId }, }); return messagingIdentityDto(deps.prisma, updated); } catch (error) { // botId is unique: the target bot is already linked elsewhere. if (isUniqueViolation(error)) { throw new ORPCError("BAD_REQUEST", { message: "That bot is already linked to a chat app.", }); } throw error; } }), unlink: authed.messaging.identities.unlink.handler(async ({ context, input }) => { const { count } = await deps.prisma.messagingIdentity.deleteMany({ where: { id: input.identityId, userId: context.actor.userId }, }); if (count === 0) throw new ORPCError("NOT_FOUND"); return { ok: true as const }; }), }, channels: { list: authed.messaging.channels.list.handler(async ({ context }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); if (identities.length === 0) return []; const memberships = await deps.prisma.messagingChannelMember.findMany({ where: { identityId: { in: identities.map((identity) => identity.id) } }, include: { channel: { include: { members: ACTIVE_CHANNEL_MEMBERS } } }, orderBy: { updatedAt: "desc" }, }); return memberships.map((membership) => messagingChannelDto(membership)); }), respond: authed.messaging.channels.respond.handler(async ({ context, input }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); const membership = identities.length ? await deps.prisma.messagingChannelMember.findFirst({ where: { id: input.membershipId, identityId: { in: identities.map((identity) => identity.id) }, }, include: { channel: { include: { members: ACTIVE_CHANNEL_MEMBERS } } }, }) : null; if (membership?.status !== "invited") { throw new ORPCError("NOT_FOUND"); } const { count } = await deps.prisma.messagingChannelMember.updateMany({ where: { id: membership.id, status: "invited" }, data: { status: input.accept ? "approved" : "declined" }, }); if (count === 0) { // Lost a race with leave/sweep: approval must not resurrect a // departed member. throw new ORPCError("NOT_FOUND"); } const updated = await deps.prisma.messagingChannelMember.findUniqueOrThrow({ where: { id: membership.id }, include: { channel: { include: { members: ACTIVE_CHANNEL_MEMBERS } } }, }); return messagingChannelDto(updated); }), leave: authed.messaging.channels.leave.handler(async ({ context, input }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); const membership = identities.length ? await deps.prisma.messagingChannelMember.findFirst({ where: { id: input.membershipId, identityId: { in: identities.map((identity) => identity.id) }, }, }) : null; if (!membership) throw new ORPCError("NOT_FOUND"); await deps.prisma.messagingChannelMember.update({ where: { id: membership.id }, data: { status: "left" }, }); return { ok: true as const }; }), }, connections: { list: authed.messaging.connections.list.handler(async ({ context }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); if (identities.length === 0) return []; const botIds = identities.map((identity) => identity.botId); const connections = await deps.prisma.agentConnection.findMany({ where: { OR: [{ requesterBotId: { in: botIds } }, { targetBotId: { in: botIds } }], }, orderBy: { updatedAt: "desc" }, }); const myBotIds = new Set(botIds); return Promise.all( connections.map((connection) => messagingConnectionDto(deps.prisma, myBotIds, connection), ), ); }), respond: authed.messaging.connections.respond.handler(async ({ context, input }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); const myBotIds = new Set(identities.map((identity) => identity.botId)); const connection = identities.length ? await deps.prisma.agentConnection.findFirst({ where: { id: input.connectionId, targetBotId: { in: [...myBotIds] }, status: "pending", }, }) : null; if (!connection) throw new ORPCError("NOT_FOUND"); const { updated, notifyRequester } = await deps.prisma.$transaction(async (tx) => { // The claim holds the connection row lock through commit, so a // revoke either beats it or waits — it can never interleave with // the confirmation write below. const { count } = await tx.agentConnection.updateMany({ where: { id: connection.id, status: "pending" }, data: { status: input.accept ? "approved" : "declined" }, }); if (count === 0) { // Lost a race with revoke: approval must never overwrite it. throw new ORPCError("NOT_FOUND"); } const row = await tx.agentConnection.findUniqueOrThrow({ where: { id: connection.id }, }); if (!input.accept) return { updated: row, notifyRequester: false }; // Parity with the text-command path: the requester hears about it. const requesterIdentity = await tx.messagingIdentity.findUnique({ where: { botId: connection.requesterBotId }, }); if (!requesterIdentity) return { updated: row, notifyRequester: false }; const key = `command:connected:${connection.id}`; // A re-approved pair starts a fresh cycle; clear the stale row or // skipDuplicates would swallow the new confirmation. await tx.messagingOutbound.deleteMany({ where: { idempotencyKey: key } }); await tx.messagingOutbound.createMany({ data: [ { idempotencyKey: key, kind: "dm", identityId: requesterIdentity.id, body: "Your connection request was accepted. Your agents can now message each other.", }, ], skipDuplicates: true, }); return { updated: row, notifyRequester: true }; }); if (notifyRequester) { await deps.jobs.enqueue(messagingDeliverJob()).catch((error) => { console.error("messaging connection confirmation enqueue error", error); }); } return messagingConnectionDto(deps.prisma, myBotIds, updated); }), revoke: authed.messaging.connections.revoke.handler(async ({ context, input }) => { const identities = await messagingIdentitiesFor(deps.prisma, context.actor.userId); const botIds = identities.map((identity) => identity.botId); const connection = identities.length ? await deps.prisma.agentConnection.findFirst({ where: { id: input.connectionId, OR: [{ requesterBotId: { in: botIds } }, { targetBotId: { in: botIds } }], }, }) : null; if (!connection) throw new ORPCError("NOT_FOUND"); // Claim + invite cancel in one transaction. The status update holds // the connection row lock through commit, so a concurrent reconnect // (FOR UPDATE) waits until both the revoke and the invite delete // finish — otherwise it could reopen and create a fresh invite that // a post-commit deleteMany would then wipe while leaving the row // pending with no approval prompt. await deps.prisma.$transaction(async (tx) => { const { count } = await tx.agentConnection.updateMany({ where: { id: connection.id, status: connection.status }, data: { status: "revoked" }, }); if (count === 0) throw new ORPCError("NOT_FOUND"); // Cancel undelivered invites, including rows the drain already // claimed (status sent, no providerHandle yet). Connect-invite // delivery holds this connection row FOR UPDATE through // sendDirect, so revoke either waits until the DM is sent or // deletes the claim before send starts. await tx.messagingOutbound.deleteMany({ where: { idempotencyKey: `connect:${connection.requesterBotId}:${connection.targetBotId}`, OR: [{ status: "pending" }, { status: "sent", providerHandle: null }], }, }); }); return { ok: true as const }; }), }, }, approvalRules: { list: authed.approvalRules.list.handler(async ({ context }) => { const rows = await deps.prisma.actionApprovalRule.findMany({ where: { spaceId: context.actor.spaceId, createdByUserId: context.actor.userId, }, orderBy: { createdAt: "asc" }, }); return rows.map((row) => ({ id: row.id, effect: row.effect as "always_allow" | "require_approval", matchKind: row.matchKind as "tool" | "connector" | "category", matchValue: row.matchValue, createdAt: row.createdAt.toISOString(), })); }), set: authed.approvalRules.set.handler(async ({ context, input }) => { const row = await deps.prisma.actionApprovalRule.upsert({ where: { spaceId_createdByUserId_effect_matchKind_matchValue: { spaceId: context.actor.spaceId, createdByUserId: context.actor.userId, effect: input.effect, matchKind: input.matchKind, matchValue: input.matchValue, }, }, create: { spaceId: context.actor.spaceId, createdByUserId: context.actor.userId, effect: input.effect, matchKind: input.matchKind, matchValue: input.matchValue, }, update: {}, }); return { id: row.id, effect: row.effect as "always_allow" | "require_approval", matchKind: row.matchKind as "tool" | "connector" | "category", matchValue: row.matchValue, createdAt: row.createdAt.toISOString(), }; }), remove: authed.approvalRules.remove.handler(async ({ context, input }) => { await deps.prisma.actionApprovalRule.deleteMany({ where: { id: input.id, spaceId: context.actor.spaceId, createdByUserId: context.actor.userId, }, }); return { ok: true as const }; }), }, autoReview: { get: authed.autoReview.get.handler(async ({ context }) => { return loadAutoReviewSettings(deps, context.actor); }), set: authed.autoReview.set.handler(async ({ context, input }) => { await deps.prisma.actionAutoReviewPreference.upsert({ where: { spaceId_userId: { spaceId: context.actor.spaceId, userId: context.actor.userId, }, }, create: { spaceId: context.actor.spaceId, userId: context.actor.userId, enabled: input.enabled, }, update: { enabled: input.enabled }, }); return loadAutoReviewSettings(deps, context.actor); }), }, artifacts: { list: authed.artifacts.list.handler(async ({ context, input }) => { await repos.getBot(context.actor, input.botId); const rows = await deps.prisma.artifact.findMany({ where: { botId: input.botId, groupId: null, spaceId: context.actor.spaceId, userId: context.actor.userId, }, }); return rows.map((row) => ({ id: row.id, botId: row.botId, groupId: row.groupId, runId: row.runId, name: row.name, mimeType: row.mimeType, size: row.size, createdAt: row.createdAt.toISOString(), })); }), create: authed.artifacts.create.handler(async ({ context, input }) => { const botId = input.botId ? (await repos.getBot(context.actor, input.botId)).id : (await groupRepos.getGroupTarget(context.actor, input.groupId!)).members[0]?.bot.id; if (!botId) throw new IsolationError(); try { return await createOwnedArtifact(deps, context.actor, { ...input, botId }); } catch (error) { if (error instanceof AttachmentValidationError) { throw new ORPCError("BAD_REQUEST", { message: error.message }); } throw error; } }), get: authed.artifacts.get.handler(async ({ context, input }) => { if (input.groupId) { const group = await groupRepos.getGroupTarget(context.actor, input.groupId); const contextBotId = group.members[0]?.bot.id; if (!contextBotId) throw new IsolationError(); return getSpaceArtifact(deps, context.actor, { artifactId: input.artifactId, groupId: input.groupId, contextBotId, }); } await repos.getBot(context.actor, input.botId!); try { return await getOwnedArtifact(deps, context.actor, { botId: input.botId!, artifactId: input.artifactId, }); } catch (error) { if (error instanceof IsolationError) throw error; throw error; } }), }, usage: { list: authed.usage.list.handler(async ({ context }) => { const rows = await deps.prisma.usageRecord.findMany({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId }, orderBy: { createdAt: "desc" }, take: 100, }); return rows.map((row) => ({ id: row.id, botId: row.botId, runId: row.runId, provider: row.provider, model: row.model, inputTokens: row.inputTokens, outputTokens: row.outputTokens, createdAt: row.createdAt.toISOString(), })); }), summary: authed.usage.summary.handler(async ({ context }) => { const result = await deps.prisma.usageRecord.aggregate({ where: { spaceId: context.actor.spaceId, userId: context.actor.userId }, _sum: { inputTokens: true, outputTokens: true }, _count: { _all: true }, }); return { inputTokens: result._sum.inputTokens ?? 0, outputTokens: result._sum.outputTokens ?? 0, runs: result._count._all, }; }), }, export: { bot: authed.export.bot.handler(async ({ context, input }) => { const bot = await repos.getBot(context.actor, input.botId); if (!bot.thread || !bot.computer) throw new IsolationError(); const homeKey = bot.computer.homeKey; const exportContext = { operationId: "export", traceId: "export", spaceId: context.actor.spaceId, userId: context.actor.userId, signal: new AbortController().signal, }; const [memory, routines, files, history] = await Promise.all([ deps.prisma.memoryDocument.findMany({ where: { botId: input.botId, spaceId: context.actor.spaceId }, }), deps.prisma.routine.findMany({ where: { botId: input.botId, spaceId: context.actor.spaceId }, }), (async () => { const exported: Array<{ path: string; content: string }> = []; for await (const file of deps.home.exportHome(homeKey, exportContext)) { exported.push({ path: file.path, content: new TextDecoder().decode(file.content), }); } return exported; })(), loadAllMessages(deps.prisma, bot.thread.id, EXPORT_MESSAGE_PAGE_SIZE), ]); return { version: 1 as const, exportedAt: new Date().toISOString(), bot: { name: bot.name, title: bot.title, description: bot.description, instructions: bot.instructions, }, memory: memory.map((m) => ({ path: m.path, content: m.content })), routines: routines.map((r) => ({ name: r.name, prompt: r.prompt, crons: r.crons, timezone: r.timezone, })), files, history, }; }), }, notifications: { registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { await savePushToken(deps.dataDir, context.actor.userId, input.token); return { ok: true as const }; }), unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => { await deletePushToken(deps.dataDir, context.actor.userId); return { ok: true as const }; }), }, search: { query: authed.search.query.handler(async ({ context, input }) => ({ hits: await querySpaceSearch(deps.prisma, context.actor, input.q), })), }, runs: { list: authed.runs.list.handler(async ({ context, input }) => ({ runs: await listSpaceRuns(deps.prisma, context.actor, input.filter), })), }, voice: { catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()), status: authed.voice.status.handler(async ({ context }) => { const cred = await findDefaultVoiceCredential(deps.prisma, context.actor); return toVoiceStatus(cred); }), credentials: authed.voice.credentials.handler(async ({ context }) => { const rows = await deps.prisma.userVoiceCredential.findMany({ where: { userId: context.actor.userId }, include: { preferences: { where: { userId: context.actor.userId, spaceId: context.actor.spaceId }, }, }, orderBy: newestVoiceCredentialOrder, }); return rows.map((row) => { const preference = row.preferences[0]; return toVoiceCredential({ ...row, isDefault: preference?.isDefault ?? false, voiceId: preference?.voiceId ?? "", }); }); }), connect: authed.voice.connect.handler(async ({ context, input }) => persistVoiceCredential(deps, context.actor, { provider: input.provider, plaintext: input.apiKey, voiceId: input.voiceId, signal: context.signal, }), ), setVoice: authed.voice.setVoice.handler(async ({ context, input }) => { const cred = await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { const found = input.provider ? await tx.userVoiceCredential.findFirst({ where: { userId: context.actor.userId, provider: input.provider }, orderBy: newestVoiceCredentialOrder, }) : ( await tx.spaceVoicePreference.findFirst({ where: { userId: context.actor.userId, spaceId: context.actor.spaceId, isDefault: true, }, include: { credential: true }, orderBy: [{ updatedAt: "desc" }, { id: "desc" }], }) )?.credential; if (!found) { throw new ORPCError("BAD_REQUEST", { message: "Connect a voice provider first." }); } // Picking a voice also makes its provider the one speak/transcribe use. await selectSpaceVoicePreference(tx, context.actor, found.id, input.voiceId); return { ...found, voiceId: input.voiceId, isDefault: true }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); return toVoiceStatus(cred); }), voices: authed.voice.voices.handler(async ({ context, input }) => { const loaded = await loadDefaultVoiceCredential(deps, context.actor); if (!loaded) return []; const providerId = input.provider ?? loaded.cred.provider; const row = providerId === loaded.cred.provider ? loaded : await loadVoiceCredential(deps, context.actor, providerId); if (!row) return []; return createVoiceProvider(row.cred.provider).listVoices( row.apiKey, voiceContext(context.actor, context.signal), ); }), prepare: authed.voice.prepare.handler(async ({ context, input }) => prepareVoice(deps, context.actor, input), ), }, }); } function updaterConfig(deps: RouterDeps): UpdaterProxyConfig { return { url: deps.env.updaterUrl ?? null, token: deps.env.updaterToken ?? null, gitSha: deps.env.gitSha, imageTag: deps.env.imageTag ?? null, }; } function mapUpdaterError(error: unknown): never { if (error instanceof UpdaterProxyError) { if (error.status === 401 || error.status === 403) { throw new ORPCError("FORBIDDEN", { message: error.message }); } if (error.status >= 500) { throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message }); } throw new ORPCError("BAD_REQUEST", { message: error.message }); } throw new ORPCError("INTERNAL_SERVER_ERROR", { message: error instanceof Error ? error.message : "Update failed.", }); } async function spaceNavigationDto( deps: RouterDeps, actor: Actor, repos: ReturnType, groupRepos: ReturnType, ): Promise { const currentSpace = await deps.prisma.space.findUnique({ where: { id: actor.spaceId }, select: { organizationId: true }, }); if (!currentSpace) throw new IsolationError(); const memberships = await deps.prisma.spaceMember.findMany({ where: { userId: actor.userId, organizationId: currentSpace.organizationId }, select: { spaceId: true, space: { select: { name: true, isDefault: true } }, }, orderBy: { createdAt: "asc" }, }); const spaceIds = memberships.map((membership) => membership.spaceId); const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId); const [currentBots, currentGroups, inactiveBots, inactiveGroups, botSections] = await Promise.all( [ repos.listBots(actor), groupRepos.listGroups(actor), repos.listSpaceBotsForSpaces(actor, inactiveSpaceIds), groupRepos.listSpaceGroupsForSpaces(actor, inactiveSpaceIds), repos.listBotSectionsForSpaces(actor, spaceIds), ], ); const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId); if (!currentMembership) throw new IsolationError(); const botsBySpace = partitionBySpace([...currentBots, ...inactiveBots]); const groupsBySpace = partitionBySpace([...currentGroups, ...inactiveGroups]); const sectionsBySpace = partitionBySpace(botSections); const botsFor = (spaceId: string) => botsBySpace.get(spaceId) ?? []; const groupsFor = (spaceId: string) => groupsBySpace.get(spaceId) ?? []; const sectionsFor = (spaceId: string) => sectionsBySpace.get(spaceId) ?? []; return { current: { id: actor.spaceId, name: currentMembership.space.name, bots: currentBots, groups: currentGroups, botSections: sectionsFor(actor.spaceId), }, spaces: memberships.map((membership) => { const spaceBots = botsFor(membership.spaceId); const spaceGroups = groupsFor(membership.spaceId); return { id: membership.spaceId, name: membership.space.name, isDefault: membership.space.isDefault, bots: spaceBots.map((bot) => ({ id: bot.id, spaceId: bot.spaceId, name: bot.name, title: bot.title, color: bot.color, avatarShape: bot.avatarShape, hasAvatarImage: bot.hasAvatarImage, notifyOnFinish: bot.notifyOnFinish, pinned: bot.pinned, sectionId: bot.sectionId, unread: bot.unread, preview: bot.preview, status: bot.status, updatedAt: bot.updatedAt, })), groups: spaceGroups.map((group) => ({ id: group.id, spaceId: group.spaceId, name: group.name, pinned: group.pinned, sectionId: group.sectionId, members: group.members, preview: group.preview, unread: group.unread, updatedAt: group.updatedAt, })), botSections: sectionsFor(membership.spaceId), }; }), }; } function partitionBySpace(rows: T[]): Map { const partitioned = new Map(); for (const row of rows) { const spaceRows = partitioned.get(row.spaceId) ?? []; spaceRows.push(row); partitioned.set(row.spaceId, spaceRows); } return partitioned; } async function loadAutoReviewSettings(deps: RouterDeps, actor: Actor) { const environmentAvailable = isAutoReviewCheckerConfigured({ env: process.env }); const checker = environmentAvailable ? null : resolveAutoReviewChecker(process.env); const requiredUserProvider = checker?.provider === "scripted" ? null : checker?.provider; const [preference, credential] = await Promise.all([ deps.prisma.actionAutoReviewPreference.findUnique({ where: { spaceId_userId: { spaceId: actor.spaceId, userId: actor.userId, }, }, select: { enabled: true }, }), requiredUserProvider ? deps.prisma.userModelCredential.findFirst({ where: { userId: actor.userId, provider: requiredUserProvider }, select: { id: true }, }) : Promise.resolve(null), ]); const enabled = preference?.enabled ?? deploymentAutoReviewDefault(process.env); const checkerAvailable = environmentAvailable || Boolean(credential); return { enabled, checkerAvailable }; } async function meDto(deps: RouterDeps, actor: Actor): Promise { const [user, cred, settings] = await Promise.all([ deps.prisma.user.findUniqueOrThrow({ where: { id: actor.userId } }), findDefaultModelCredential(deps.prisma, actor), deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), ]); const hasDeployment = Boolean( settings?.deploymentModelCredentialCipher || deps.env.deploymentModelKey, ); return { userId: actor.userId, email: user.email, name: user.name, spaceId: actor.spaceId, isDeploymentOwner: actor.isDeploymentOwner, needsModel: !cred && !hasDeployment, defaultProvider: cred?.provider ?? settings?.defaultModelProvider ?? deps.env.defaultProvider, defaultModel: cred?.defaultModel ?? settings?.defaultModelId ?? deps.env.defaultModel, computerHost: computerHostFor(settings?.computerHost, deps.env.sandboxProvider), canChooseHostComputer: actor.isDeploymentOwner && deps.env.sandboxProvider === "docker", sandboxProvider: deps.env.sandboxProvider, avatarStyle: user.avatarStyle === "robot" ? "robot" : "organic", }; } async function computerStatus( deps: RouterDeps, actor: Actor, botId: string, ): Promise { const repos = createRepos(deps.prisma); let bot = await repos.getBot(actor, botId); if (await expireStaleComputerControl(deps, bot.computer)) { bot = await repos.getBot(actor, botId); } const busyBotName = await resolveBusyBotName(deps.prisma, { computerId: bot.computer?.id, botId, botName: bot.name, }); return toComputerStatus(botId, bot.computer, busyBotName); } async function runComputerReplace( deps: RouterDeps, context: { actor: Actor }, botId: string, mode: "recover" | "reset" | "update", operationId: string, ): Promise { const repos = createRepos(deps.prisma); const bot = await repos.getBot(context.actor, botId); if (!bot.computer) throw new IsolationError(); if (mode === "update" && !computerSupportsUpdate(bot.computer.kind)) { throw new ORPCError("BAD_REQUEST", { message: "Computer update is not available on this device", }); } const manualRunId = `${mode}:${randomUUID()}`; let lease: ComputerExecutionLease | null; try { lease = await acquireComputerExecutionLease(deps.prisma, { computerId: bot.computer.id, runId: manualRunId, botId: bot.id, }); } catch (error) { if (error instanceof ComputerBusyError) { throw new ORPCError("CONFLICT", { message: "Computer is busy" }); } throw error; } try { await replaceComputer(deps, bot.computer.id, mode, { ...computerContext(context.actor, bot.id, operationId), screenLeaseId: screenLeaseIdForRun(lease, manualRunId), }); scheduleComputerSleep(deps.jobs, bot.computer.id); } catch (error) { if (error instanceof ComputerBusyError) { throw new ORPCError("CONFLICT", { message: "Computer is busy" }); } throw error; } finally { await releaseComputerExecutionLease(deps.prisma, lease); } return computerStatus(deps, context.actor, botId); } async function expireStaleComputerControl( deps: RouterDeps, computer: | (NonNullable[0]> & { id: string }) | null | undefined, ): Promise { const leaseId = computer?.controlLeaseId; if (!leaseId || hasActiveComputerControl(computer)) return false; await expireComputerControl(deps, computer.id, leaseId).catch(() => undefined); return true; } async function computerScreenContext( prisma: PrismaClient, actor: Actor, computerId: string, botId: string, operationId: string, ): Promise { const context = computerContext(actor, botId, operationId); const lease = await prisma.computerExecutionLease.findUnique({ where: { computerId_botId: { computerId, botId } }, select: { runId: true, fence: true, expiresAt: true }, }); if (!lease || lease.expiresAt.getTime() <= Date.now()) return context; return { ...context, screenLeaseId: screenLeaseIdForRun(lease, lease.runId) }; } async function deploymentDto(prisma: PrismaClient, sandboxProvider: string) { const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" } }); return { ownerUserId: settings?.ownerUserId ?? null, signupsEnabled: settings?.signupsEnabled ?? true, signupAllowlist: settings?.signupAllowlist ? settings.signupAllowlist.split(",").filter(Boolean) : [], hasDeploymentModelCredential: Boolean(settings?.deploymentModelCredentialCipher), defaultProvider: settings?.defaultModelProvider ?? null, defaultModel: settings?.defaultModelId ?? null, computerHost: computerHostFor(settings?.computerHost, sandboxProvider), canChooseHostComputer: sandboxProvider === "docker", sandboxProvider, }; } function computerHostFor( stored: string | null | undefined, sandboxProvider: string, ): "docker" | "this-mac" | null { if (sandboxProvider === "desktop") return "this-mac"; if (sandboxProvider !== "docker") return null; if (stored === "this-mac" || stored === "docker") return stored; return null; } async function persistModelCredential( deps: RouterDeps, actor: Actor, input: { provider: string; plaintext: string; label?: string; modelId?: string; signal?: AbortSignal; }, ) { throwIfAborted(input.signal); const stored = await deps.secrets.put(input.plaintext, { operationId: "cred", traceId: "cred", spaceId: actor.spaceId, userId: actor.userId, signal: input.signal ?? new AbortController().signal, }); throwIfAborted(input.signal); const cred = await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { throwIfAborted(input.signal); const existing = await tx.userModelCredential.findFirst({ where: { userId: actor.userId, provider: input.provider }, orderBy: newestModelCredentialOrder, }); throwIfAborted(input.signal); const secret = await tx.secret.create({ data: { id: stored.id, userId: actor.userId, spaceId: null, kind: "model", ciphertext: stored.ciphertext, }, }); throwIfAborted(input.signal); const credential = !existing ? await tx.userModelCredential.create({ data: { userId: actor.userId, provider: input.provider, label: input.label ?? input.provider, secretId: secret.id, }, }) : await tx.userModelCredential.update({ where: { id: existing.id }, data: { label: input.label ?? input.provider, secretId: secret.id, }, }); throwIfAborted(input.signal); const defaultModel = input.modelId ?? deps.env.defaultModel; await selectSpaceModelPreference(tx, actor, credential.id, defaultModel); throwIfAborted(input.signal); if (existing) { await deleteUnreferencedCredentialSecret(tx, { credentialKind: "model", credentialId: existing.id, secretId: existing.secretId, }); throwIfAborted(input.signal); } return { ...credential, isDefault: true, defaultModel }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); return modelCredentialDto(cred, input.plaintext); } async function requireSpaceOwner(prisma: PrismaClient, actor: Actor): Promise { const member = await prisma.spaceMember.findUnique({ where: { spaceId_userId: { spaceId: actor.spaceId, userId: actor.userId } }, select: { role: true }, }); const roles = member?.role.split(",").map((role) => role.trim()); if (!roles?.includes("owner")) throw new ORPCError("FORBIDDEN"); } export async function persistMemoryProviderConfig( deps: RouterDeps, actor: Actor, input: { provider: string; settings: Record; credentials: Record; defaultMemoryScope: "isolated" | "shared"; }, ) { await requireSpaceOwner(deps.prisma, actor); const prepared = await prepareMemoryProviderConnection(input).catch((error: unknown) => { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "Memory provider connection failed", }); }); const stored = await deps.secrets.put(JSON.stringify(prepared.credentials), { operationId: "memory-provider-config", traceId: "memory-provider-config", spaceId: actor.spaceId, userId: actor.userId, signal: new AbortController().signal, }); const config = await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { const existing = await findSpaceMemoryConfig(tx, actor.spaceId); const secret = await tx.secret.create({ data: { id: stored.id, userId: actor.userId, spaceId: actor.spaceId, kind: "memory-provider", ciphertext: stored.ciphertext, }, }); const updated = await tx.spaceMemoryConfig.upsert({ where: { spaceId: actor.spaceId }, create: { spaceId: actor.spaceId, userId: actor.userId, provider: prepared.provider, settings: prepared.settings, secretId: secret.id, defaultMemoryScope: input.defaultMemoryScope, }, update: { userId: actor.userId, provider: prepared.provider, settings: prepared.settings, secretId: secret.id, defaultMemoryScope: input.defaultMemoryScope, }, }); if (existing && existing.secretId !== secret.id) { await tx.secret.deleteMany({ where: { id: existing.secretId } }); } return updated; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ), ); return serializeSpaceMemoryConfig(config); } export async function updateMemoryProviderDefaultScope( deps: RouterDeps, actor: Actor, defaultMemoryScope: "isolated" | "shared", ) { await requireSpaceOwner(deps.prisma, actor); const existing = await findSpaceMemoryConfig(deps.prisma, actor.spaceId); if (!existing) throw new ORPCError("NOT_FOUND"); const updated = await deps.prisma.spaceMemoryConfig.update({ where: { id: existing.id }, data: { defaultMemoryScope }, }); return serializeSpaceMemoryConfig(updated); } function serializeSpaceMemoryConfig(config: { provider: string; settings: unknown; defaultMemoryScope: string; updatedAt: Date; }) { return { provider: config.provider, settings: toStringRecord(config.settings), defaultMemoryScope: config.defaultMemoryScope as "isolated" | "shared", updatedAt: config.updatedAt.toISOString(), }; } function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled"); } function nextRoutineDate(crons: string[], timezone: string): Date { let next: Date | null; try { next = nextCronDateAcrossStrict(crons, new Date(), timezone); } catch { throw new ORPCError("BAD_REQUEST", { message: "Enter a valid cron expression." }); } if (!next) throw new ORPCError("BAD_REQUEST", { message: "Enter a valid cron expression." }); return next; } function mapRoutine(row: { id: string; botId: string; name: string; prompt: string; crons: string[]; timezone: string; active: boolean; notify: boolean; webhookEnabled: boolean; lastRunAt: Date | null; nextRunAt: Date | null; createdAt: Date; }) { return { id: row.id, botId: row.botId, name: row.name, prompt: row.prompt, crons: row.crons, timezone: row.timezone, active: row.active, notify: row.notify, webhookEnabled: row.webhookEnabled, lastRunAt: row.lastRunAt?.toISOString() ?? null, nextRunAt: row.nextRunAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } async function listRoutinesDto(deps: RouterDeps, actor: Actor, botId: string) { const rows = await deps.prisma.routine.findMany({ where: { botId, spaceId: actor.spaceId }, }); return rows.map(mapRoutine); } function withViewOnly(url: string, viewOnly: boolean) { try { const parsed = new URL(url); parsed.searchParams.set("view_only", viewOnly ? "true" : "false"); return parsed.toString(); } catch { const join = url.includes("?") ? "&" : "?"; return `${url}${join}view_only=${viewOnly ? "true" : "false"}`; } } function duplicateBotName(name: string) { return `${name.slice(0, 75)} copy`; } const ACTIVE_CHANNEL_MEMBERS = { where: { status: { in: ["invited", "approved"] } }, select: { id: true }, }; type MessagingIdentityRecord = { id: string; botId: string; }; /** Every linked chat app counts: channels and connections span identities. */ async function messagingIdentitiesFor( prisma: PrismaClient, userId: string, ): Promise { return prisma.messagingIdentity.findMany({ where: { userId }, orderBy: { createdAt: "asc" }, select: { id: true, botId: true }, }); } async function messagingIdentityDto( prisma: PrismaClient, identity: { id: string; provider: string; address: string; botId: string }, ) { const bot = await prisma.bot.findUnique({ where: { id: identity.botId }, select: { name: true }, }); return { id: identity.id, provider: identity.provider, address: identity.address, botId: identity.botId, botName: bot?.name ?? "Assistant", }; } function isUniqueViolation(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002"); } function messagingChannelDto(membership: { id: string; channelId: string; // Never null here: every membership reaching this DTO was matched by one of // the caller's identity ids. identityId: string | null; status: string; channel: { provider: string; name: string | null; members: Array<{ id: string }> }; }) { return { id: membership.id, channelId: membership.channelId, identityId: membership.identityId!, provider: membership.channel.provider, name: membership.channel.name, status: membership.status as "invited" | "approved" | "declined" | "left", memberCount: membership.channel.members.length, }; } async function messagingConnectionDto( prisma: PrismaClient, myBotIds: ReadonlySet, connection: { id: string; requesterBotId: string; targetBotId: string; status: string; }, ) { const incoming = myBotIds.has(connection.targetBotId); // The target's identity stays opaque until they approve (mirrors connect_agent). if (!incoming && connection.status !== "approved") { return { id: connection.id, peerBotName: "agent", peerOwnerLabel: "owner", status: connection.status as "pending" | "approved" | "declined" | "revoked", incoming, }; } const peerBotId = incoming ? connection.requesterBotId : connection.targetBotId; const peerBot = await prisma.bot.findUnique({ where: { id: peerBotId }, select: { name: true }, }); const peerIdentity = await prisma.messagingIdentity.findUnique({ where: { botId: peerBotId }, select: { userId: true }, }); const peerOwner = peerIdentity ? await prisma.user.findUnique({ where: { id: peerIdentity.userId }, select: { name: true }, }) : null; return { id: connection.id, peerBotName: peerBot?.name ?? "agent", peerOwnerLabel: peerOwner?.name.trim().split(/\s+/)[0] || "owner", status: connection.status as "pending" | "approved" | "declined" | "revoked", incoming, }; }