Files
curriculum-project-hub/hub/src/feishu/projectConsole.ts
T

382 lines
15 KiB
TypeScript

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<FolderDestinationPage> {
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<ProjectConsoleState> {
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<string, unknown>)["claudeSessionId"];
return typeof value === "string" && value !== "";
}
function isUserResumable(metadata: unknown): boolean {
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) &&
(metadata as Record<string, unknown>)["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<void> {
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<boolean> {
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<readonly { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number }[]> {
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<void> {
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<string | null> {
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<string> {
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(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;
}