forked from bai/curriculum-project-hub
382 lines
15 KiB
TypeScript
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;
|
|
}
|