import type { PrismaClient } from "@prisma/client"; import { Prisma } from "@prisma/client"; import { assertSupportedRoleTools } from "./roleTools.js"; import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } 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 { 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 { 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 }> { const storeRoot = this.requireSkillStoreRoot(); await this.requireActiveOrganization(input.organizationId); const version = nonEmpty(input.version, "skill version"); const imported = await importSkillDirectory({ sourceDir: input.sourceDir, storeRoot, }); return this.commitInstalledSkill({ organizationId: input.organizationId, imported, version, action: "agent_skill.installed", }); } /** * Install or replace a skill from an in-memory file list (web upload path). * Same content-addressed storage, same session archival semantics as * `installSkill`; only the ingestion source differs. */ async installSkillFromFiles(input: { readonly organizationId: string; readonly files: readonly SkillFileInput[]; readonly version: string; }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { const storeRoot = this.requireSkillStoreRoot(); await this.requireActiveOrganization(input.organizationId); const version = nonEmpty(input.version, "skill version"); const imported = await importSkillFromFiles({ files: input.files, storeRoot, }); return this.commitInstalledSkill({ organizationId: input.organizationId, imported, version, action: "agent_skill.installed", }); } /** * Read all files from the stored skill version identified by content digest. * Returns UTF-8 content for each file; the web editor uses this to populate * its file tree. */ async readSkillFiles(input: { readonly organizationId: string; readonly contentDigest: string; }): Promise { const storeRoot = this.requireSkillStoreRoot(); await this.requireActiveOrganization(input.organizationId); const skill = await this.prisma.organizationAgentSkill.findFirst({ where: { organizationId: input.organizationId, contentDigest: input.contentDigest, }, select: { id: true, disabledAt: true }, }); if (skill === null) { throw new Error(`skill not found in organization for digest: ${input.contentDigest}`); } return readSkillFiles({ storeRoot, contentDigest: input.contentDigest, }); } /** * Disable a skill (soft-delete). Sets `disabledAt` and archives active * sessions of every role bound to this skill, since the execution surface * changes when a bound skill disappears. */ async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise { await this.requireActiveOrganization(input.organizationId); await this.prisma.$transaction(async (tx) => { const skill = await tx.organizationAgentSkill.findUnique({ where: { organizationId_name: { organizationId: input.organizationId, name: input.name } }, select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } }, }); if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`); if (skill.disabledAt !== null) return; await tx.organizationAgentSkill.update({ where: { id: skill.id }, data: { disabledAt: new Date() }, }); await archiveRoleSessions( tx, input.organizationId, skill.roleBindings.map((binding) => binding.role.roleId), ); await tx.auditEntry.create({ data: { organizationId: input.organizationId, action: "agent_skill.disabled", metadata: { name: input.name }, }, }); }); } /** * Update only the `description` label of a skill. This is a label change, * not an execution-surface change — no session archival (ADR-0017). */ async updateSkillDescription(input: { readonly organizationId: string; readonly name: string; readonly description: string; }): Promise { await this.requireActiveOrganization(input.organizationId); const description = input.description.trim(); await this.prisma.$transaction(async (tx) => { const skill = await tx.organizationAgentSkill.findUnique({ where: { organizationId_name: { organizationId: input.organizationId, name: input.name } }, select: { id: true, disabledAt: true }, }); if (skill === null || skill.disabledAt !== null) { throw new Error(`active skill not found in organization: ${input.name}`); } await tx.organizationAgentSkill.update({ where: { id: skill.id }, data: { description: description === "" ? null : description }, }); await tx.auditEntry.create({ data: { organizationId: input.organizationId, action: "agent_skill.description_updated", metadata: { name: input.name, description: description === "" ? null : description }, }, }); }); } private requireSkillStoreRoot(): string { if (this.skillStoreRoot === null) { throw new Error("skill store root is not configured for this OrganizationAgentConfiguration"); } return this.skillStoreRoot; } /** * Shared DB commit for an imported skill: upsert the skill record, archive * affected sessions if the content digest changed, and write an audit entry. */ private async commitInstalledSkill(input: { readonly organizationId: string; readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string }; readonly version: string; readonly action: string; }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { return this.prisma.$transaction(async (tx) => { const previous = await tx.organizationAgentSkill.findUnique({ where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } }, select: { contentDigest: true }, }); const skill = await tx.organizationAgentSkill.upsert({ where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name, }, }, create: { organizationId: input.organizationId, name: input.imported.name, version: input.version, description: input.imported.description ?? null, contentDigest: input.imported.contentDigest, }, update: { version: input.version, description: input.imported.description ?? null, contentDigest: input.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: input.action, metadata: { name: skill.name, version: input.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 { 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 { 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 { 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 { 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), }; }