Files
curriculum-project-hub/hub/src/agent/configuration.ts
T
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00

413 lines
15 KiB
TypeScript

import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "./roleTools.js";
import { importSkillDirectory } from "./skillStore.js";
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export interface AgentRoleRow {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: readonly string[] | null;
readonly sortOrder: number;
readonly isDefault: boolean;
readonly disabledAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
}
export interface AgentSkillRow {
readonly id: string;
readonly name: string;
readonly version: string;
readonly description: string | null;
readonly contentDigest: string;
readonly disabledAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
readonly boundRoleIds: readonly string[];
}
/**
* Deep module for controlled host-console Agent configuration. It owns the
* filesystem/DB ordering, Organization checks and role-skill composition so
* callers never manipulate registry rows or content paths independently.
*/
export class OrganizationAgentConfiguration {
constructor(
private readonly prisma: PrismaClient,
private readonly skillStoreRoot: string | null,
) {}
async listRoles(input: { readonly organizationId: string }): Promise<readonly AgentRoleRow[]> {
await this.requireActiveOrganization(input.organizationId);
const roles = await this.prisma.organizationAgentRole.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
});
return roles.map((role) => toRoleRow(role));
}
async listSkills(input: { readonly organizationId: string }): Promise<readonly AgentSkillRow[]> {
await this.requireActiveOrganization(input.organizationId);
const skills = await this.prisma.organizationAgentSkill.findMany({
where: { organizationId: input.organizationId, disabledAt: null },
orderBy: [{ name: "asc" }],
include: {
roleBindings: {
where: { role: { disabledAt: null } },
select: { role: { select: { roleId: true } } },
},
},
});
return skills.map((skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
description: skill.description,
contentDigest: skill.contentDigest,
disabledAt: skill.disabledAt === null ? null : skill.disabledAt.toISOString(),
createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
}));
}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
if (this.skillStoreRoot === null) {
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
}
await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version");
const imported = await importSkillDirectory({
sourceDir: input.sourceDir,
storeRoot: this.skillStoreRoot,
});
return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
select: { contentDigest: true },
});
const skill = await tx.organizationAgentSkill.upsert({
where: {
organizationId_name: {
organizationId: input.organizationId,
name: imported.name,
},
},
create: {
organizationId: input.organizationId,
name: imported.name,
version,
description: imported.description ?? null,
contentDigest: imported.contentDigest,
},
update: {
version,
description: imported.description ?? null,
contentDigest: imported.contentDigest,
disabledAt: null,
},
select: {
id: true,
name: true,
contentDigest: true,
roleBindings: { select: { role: { select: { roleId: true } } } },
},
});
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
await archiveRoleSessions(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
);
}
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.installed",
metadata: {
name: skill.name,
version,
contentDigest: skill.contentDigest,
},
},
});
return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest };
});
}
async upsertRole(input: {
readonly organizationId: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel?: string | null | undefined;
readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined;
readonly isDefault?: boolean | undefined;
}): Promise<AgentRoleRow> {
await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
const label = nonEmpty(input.label, "role label");
if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]);
const sortOrder = input.sortOrder ?? 0;
if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer");
const createTools = input.tools === undefined || input.tools === null
? Prisma.DbNull
: [...input.tools];
const updateTools = input.tools === undefined
? undefined
: input.tools === null
? Prisma.DbNull
: [...input.tools];
return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { defaultModel: true, systemPrompt: true, tools: true, isDefault: true },
});
const currentDefault = await tx.organizationAgentRole.findFirst({
where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
select: { id: true },
});
const effectiveIsDefault = input.isDefault ?? previous?.isDefault ?? (currentDefault === null);
if (input.isDefault === false && previous?.isDefault === true) {
throw new Error("cannot unset the active default role without selecting a replacement");
}
if (effectiveIsDefault) {
await tx.organizationAgentRole.updateMany({
where: { organizationId: input.organizationId, isDefault: true },
data: { isDefault: false },
});
}
const role = await tx.organizationAgentRole.upsert({
where: {
organizationId_roleId: {
organizationId: input.organizationId,
roleId: input.roleId,
},
},
create: {
organizationId: input.organizationId,
roleId: input.roleId,
label,
defaultModel: normalizeOptionalText(input.defaultModel),
systemPrompt: normalizeOptionalText(input.systemPrompt),
tools: createTools,
sortOrder,
isDefault: effectiveIsDefault,
},
update: {
label,
...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}),
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
...(updateTools !== undefined ? { tools: updateTools } : {}),
sortOrder,
isDefault: effectiveIsDefault,
disabledAt: null,
},
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
select: { skill: { select: { name: true, disabledAt: true } } },
},
},
});
const executionSurfaceChanged = previous !== null && (
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
);
const activeDefaultCount = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, isDefault: true, disabledAt: null },
});
if (activeDefaultCount !== 1) {
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
}
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.upserted",
metadata: {
roleId: input.roleId,
label,
defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel),
systemPromptConfigured: input.systemPrompt === undefined
? "unchanged"
: normalizeOptionalText(input.systemPrompt) !== null,
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
sortOrder,
isDefault: effectiveIsDefault,
defaultExplicit: input.isDefault !== undefined,
},
},
});
return toRoleRow(role);
});
}
async setRoleSkills(input: {
readonly organizationId: string;
readonly roleId: string;
readonly skillNames: readonly string[];
}): Promise<void> {
const uniqueNames = new Set(input.skillNames);
if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique");
await this.prisma.$transaction(async (tx) => {
const role = await tx.organizationAgentRole.findUnique({
where: {
organizationId_roleId: {
organizationId: input.organizationId,
roleId: input.roleId,
},
},
select: { id: true, disabledAt: true },
});
if (role === null || role.disabledAt !== null) {
throw new Error(`active role not found in organization: ${input.roleId}`);
}
const skills = await tx.organizationAgentSkill.findMany({
where: {
organizationId: input.organizationId,
name: { in: [...input.skillNames] },
disabledAt: null,
},
select: { id: true, name: true },
});
if (skills.length !== input.skillNames.length) {
const found = new Set(skills.map((skill) => skill.name));
const missing = input.skillNames.filter((name) => !found.has(name));
throw new Error(`active skills not found in organization: ${missing.join(", ")}`);
}
const byName = new Map(skills.map((skill) => [skill.name, skill.id]));
await tx.organizationAgentRoleSkill.deleteMany({
where: { organizationId: input.organizationId, agentRoleId: role.id },
});
if (input.skillNames.length > 0) {
await tx.organizationAgentRoleSkill.createMany({
data: input.skillNames.map((name, index) => ({
organizationId: input.organizationId,
agentRoleId: role.id,
agentSkillId: byName.get(name)!,
sortOrder: index,
})),
});
}
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.skills_set",
metadata: { roleId: input.roleId, skillNames: [...input.skillNames] },
},
});
});
}
private async requireActiveOrganization(organizationId: string): Promise<void> {
const organization = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { status: true },
});
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
if (organization.status !== "ACTIVE") {
throw new Error(`organization ${organizationId} is ${organization.status}`);
}
}
}
async function archiveRoleSessions(
tx: Prisma.TransactionClient,
organizationId: string,
roleIds: readonly string[],
): Promise<void> {
if (roleIds.length === 0) return;
const sessions = await tx.agentSession.findMany({
where: {
roleId: { in: [...new Set(roleIds)] },
project: { organizationId },
},
select: { id: true, archivedAt: true, metadata: true },
});
const archivedAt = new Date();
for (const session of sessions) {
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
? session.metadata as Prisma.JsonObject
: {};
await tx.agentSession.update({
where: { id: session.id },
data: {
...(session.archivedAt === null ? { archivedAt } : {}),
metadata: { ...metadata, userResumable: false },
},
});
}
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
return normalized;
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const normalized = value.trim();
return normalized === "" ? null : normalized;
}
function parseTools(value: Prisma.JsonValue): readonly string[] | null {
if (value === null) return null;
if (!Array.isArray(value)) return null;
return value.filter((t): t is string => typeof t === "string");
}
function toRoleRow(role: {
readonly id: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel: string | null;
readonly systemPrompt: string | null;
readonly tools: Prisma.JsonValue;
readonly sortOrder: number;
readonly isDefault: boolean;
readonly disabledAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>;
}): AgentRoleRow {
return {
id: role.id,
roleId: role.roleId,
label: role.label,
defaultModel: role.defaultModel,
systemPrompt: role.systemPrompt,
tools: parseTools(role.tools),
sortOrder: role.sortOrder,
isDefault: role.isDefault,
disabledAt: role.disabledAt === null ? null : role.disabledAt.toISOString(),
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
};
}