import { Prisma, type PrismaClient } from "@prisma/client"; import { lockActiveProjectOrganization } from "../org/status.js"; export interface ProjectConsoleState { readonly organizationId: string; readonly projectId: string; readonly projectName: string; readonly folderId: string | null; readonly breadcrumb: string; readonly selectedRole: { readonly id: string; readonly roleId: string; readonly label: string }; readonly roles: readonly { readonly id: string; readonly roleId: string; readonly label: string }[]; readonly currentSession: { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number; readonly sdkSessionReady: boolean; } | null; } export interface FolderDestinationPage { readonly folderId: string | null; readonly parentFolderId: string | null; readonly breadcrumb: string; readonly childFolders: readonly { readonly id: string; readonly name: string }[]; } export async function browseFolderDestinations( prisma: PrismaClient, input: { readonly organizationId: string; readonly folderId: string | null }, ): Promise { const folder = input.folderId === null ? null : await prisma.folder.findFirst({ where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null }, select: { id: true, parentId: true }, }); if (input.folderId !== null && folder === null) throw new Error(`active folder not found: ${input.folderId}`); const childFolders = await prisma.folder.findMany({ where: { organizationId: input.organizationId, parentId: input.folderId, archivedAt: null, kind: { not: "SYSTEM_INBOX" }, }, orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }], select: { id: true, name: true }, }); return { folderId: input.folderId, parentFolderId: folder?.parentId ?? null, breadcrumb: input.folderId === null ? "根目录" : await folderBreadcrumb(prisma, input.folderId), childFolders, }; } export async function createFolderAndMoveProject( prisma: PrismaClient, input: { readonly organizationId: string; readonly projectId: string; readonly parentFolderId: string | null; readonly name: string; readonly actorUserId?: string | undefined; }, ): Promise<{ readonly folderId: string; readonly folderName: string }> { const name = input.name.trim(); if (name === "") throw new Error("folder name is required"); if (name.length > 100) throw new Error("folder name must not exceed 100 characters"); return prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, input.projectId); const project = await tx.project.findFirst({ where: { id: input.projectId, organizationId: input.organizationId, archivedAt: null }, select: { id: true, folderId: true }, }); if (project === null) throw new Error("active project not found in Organization"); if (input.parentFolderId !== null) { const parent = await tx.folder.findFirst({ where: { id: input.parentFolderId, organizationId: input.organizationId, archivedAt: null, kind: { not: "SYSTEM_INBOX" }, }, select: { id: true }, }); if (parent === null) throw new Error("active destination folder not found in Organization"); } const folder = await tx.folder.create({ data: { organizationId: input.organizationId, parentId: input.parentFolderId, name, }, select: { id: true, name: true }, }); await tx.project.update({ where: { id: project.id }, data: { folderId: folder.id } }); await tx.auditEntry.create({ data: { organizationId: input.organizationId, projectId: project.id, ...(input.actorUserId !== undefined ? { actorUserId: input.actorUserId } : {}), action: "folder.created_and_project_moved_from_feishu", metadata: { folderId: folder.id, parentFolderId: input.parentFolderId, previousFolderId: project.folderId, name: folder.name, }, }, }); return { folderId: folder.id, folderName: folder.name }; }); } export async function loadProjectConsole( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string }, ): Promise { const binding = await prisma.projectGroupBinding.findFirst({ where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, select: { selectedRole: { select: { id: true, roleId: true, label: true, disabledAt: true, organizationId: true } }, project: { select: { id: true, name: true, folderId: true, organizationId: true, archivedAt: true, organization: { select: { status: true, agentRoles: { where: { disabledAt: null }, orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }], select: { id: true, roleId: true, label: true }, }, }, }, }, }, }, }); if (binding === null) throw new Error("project console requires an active binding to this chat"); const { project, selectedRole } = binding; if (project.archivedAt !== null) throw new Error(`project ${project.id} is archived`); if (project.organization.status !== "ACTIVE") { throw new Error(`organization ${project.organizationId} is ${project.organization.status}`); } if (selectedRole.disabledAt !== null || selectedRole.organizationId !== project.organizationId) { throw new Error("project group selected role is unavailable or cross-Organization"); } const model = await selectedRoleModel(prisma, project.organizationId, selectedRole.id); const currentSession = await prisma.agentSession.findFirst({ where: { projectId: project.id, roleId: selectedRole.roleId, ...(model === null ? {} : { model }), archivedAt: null, }, orderBy: { updatedAt: "desc" }, select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } }, }); return { organizationId: project.organizationId, projectId: project.id, projectName: project.name, folderId: project.folderId, breadcrumb: project.folderId === null ? "根目录" : await folderBreadcrumb(prisma, project.folderId), selectedRole: { id: selectedRole.id, roleId: selectedRole.roleId, label: selectedRole.label }, roles: project.organization.agentRoles, currentSession: currentSession === null ? null : { id: currentSession.id, title: currentSession.title, updatedAt: currentSession.updatedAt, runCount: currentSession._count.runs, sdkSessionReady: hasClaudeSessionId(currentSession.metadata), }, }; } function hasClaudeSessionId(metadata: unknown): boolean { if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) return false; const value = (metadata as Record)["claudeSessionId"]; return typeof value === "string" && value !== ""; } function isUserResumable(metadata: unknown): boolean { return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) && (metadata as Record)["userResumable"] === true; } function userResumableMetadata(metadata: unknown, value: boolean): Prisma.InputJsonObject { const base = typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? metadata as Prisma.JsonObject : {}; return { ...base, userResumable: value }; } export async function selectProjectGroupRole( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string; readonly agentRoleId: string; readonly actorUserId: string; }, ): Promise { await prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, input.projectId); const binding = await tx.projectGroupBinding.findFirst({ where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, select: { id: true, project: { select: { organizationId: true } } }, }); if (binding === null) throw new Error("role selection requires an active binding to this chat"); const role = await tx.organizationAgentRole.findFirst({ where: { id: input.agentRoleId, organizationId: binding.project.organizationId, disabledAt: null, }, select: { id: true, roleId: true }, }); if (role === null) throw new Error(`active role not found: ${input.agentRoleId}`); await tx.projectGroupBinding.update({ where: { id: binding.id }, data: { selectedAgentRoleId: role.id }, }); await tx.auditEntry.create({ data: { projectId: input.projectId, actorUserId: input.actorUserId, action: "project_group.role_selected", metadata: { chatId: input.chatId, roleId: role.roleId }, }, }); }); } export async function archiveCurrentRoleSession( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string; readonly actorUserId: string }, ): Promise { return prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, input.projectId); const activeRun = await tx.agentRun.findFirst({ where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } }, select: { id: true }, }); if (activeRun !== null) throw new Error(`cannot archive a session while run ${activeRun.id} is active`); const binding = await tx.projectGroupBinding.findFirst({ where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, select: { selectedRole: { select: { roleId: true } } }, }); if (binding === null) throw new Error("session operation requires an active binding to this chat"); const session = await tx.agentSession.findFirst({ where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: null }, orderBy: { updatedAt: "desc" }, select: { id: true, metadata: true }, }); if (session !== null) { await tx.agentSession.update({ where: { id: session.id }, data: { archivedAt: new Date(), metadata: userResumableMetadata(session.metadata, true) }, }); } await tx.auditEntry.create({ data: { projectId: input.projectId, actorUserId: input.actorUserId, action: "agent_session.new_requested", metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, archivedSessionId: session?.id ?? null }, }, }); return session !== null; }); } export async function listRoleSessionHistory( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string }, ): Promise { const binding = await prisma.projectGroupBinding.findFirst({ where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, select: { selectedRole: { select: { roleId: true } } }, }); if (binding === null) throw new Error("session history requires an active binding to this chat"); const sessions = await prisma.agentSession.findMany({ where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } }, orderBy: { updatedAt: "desc" }, take: 10, select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } }, }); return sessions.filter((session) => isUserResumable(session.metadata)).map((session) => ({ id: session.id, title: session.title, updatedAt: session.updatedAt, runCount: session._count.runs, })); } export async function resumeRoleSession( prisma: PrismaClient, input: { readonly projectId: string; readonly chatId: string; readonly sessionId: string; readonly actorUserId: string; }, ): Promise { await prisma.$transaction(async (tx) => { await lockActiveProjectOrganization(tx, input.projectId); const activeRun = await tx.agentRun.findFirst({ where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } }, select: { id: true }, }); if (activeRun !== null) throw new Error(`cannot resume a session while run ${activeRun.id} is active`); const binding = await tx.projectGroupBinding.findFirst({ where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null }, select: { selectedRole: { select: { roleId: true } } }, }); if (binding === null) throw new Error("session resume requires an active binding to this chat"); const target = await tx.agentSession.findFirst({ where: { id: input.sessionId, projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } }, select: { id: true, provider: true, model: true, metadata: true }, }); if (target === null || !isUserResumable(target.metadata)) { throw new Error("user-resumable archived session not found for the selected role"); } const activeSessions = await tx.agentSession.findMany({ where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, provider: target.provider, model: target.model, archivedAt: null, }, select: { id: true, metadata: true }, }); const archivedAt = new Date(); for (const session of activeSessions) { await tx.agentSession.update({ where: { id: session.id }, data: { archivedAt, metadata: userResumableMetadata(session.metadata, true) }, }); } await tx.agentSession.update({ where: { id: target.id }, data: { archivedAt: null, metadata: userResumableMetadata(target.metadata, false) }, }); await tx.auditEntry.create({ data: { projectId: input.projectId, actorUserId: input.actorUserId, action: "agent_session.resumed", metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, sessionId: target.id }, }, }); }); } async function selectedRoleModel( prisma: PrismaClient, organizationId: string, roleId: string, ): Promise { const role = await prisma.organizationAgentRole.findFirst({ where: { id: roleId, organizationId, disabledAt: null }, select: { defaultModel: true }, }); if (role === null) throw new Error(`selected role not found: ${roleId}`); return role.defaultModel; } async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise { const rows = await prisma.$queryRaw>(Prisma.sql` SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb `); const breadcrumb = rows[0]?.breadcrumb; if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`); return breadcrumb; }