forked from EduCraft/curriculum-project-hub
79f72ecca8
Add full skill lifecycle to the org-admin web surface: create, read, edit, disable. Skills are directories (SKILL.md manifest + supporting files), content-addressed by SHA-256 in an immutable store. Backend: - skillStore: extract commitSkillContent (shared populate→inspect→ dedup→atomic rename); add importSkillFromFiles (in-memory file list ingestion) and readSkillFiles (read stored version back as UTF-8) - configuration: add installSkillFromFiles, readSkillFiles, disableSkill (soft-delete + archive bound role sessions), updateSkillDescription (label-only, no archival); refactor installSkill to share commitInstalledSkill - agentConfigRoutes: wire skillStoreRoot; add GET /agent-skills/:name/files, PUT /agent-skills/:name (create/replace), PATCH /agent-skills/:name (description/disable) - orgRoutes: pass readSkillStoreRoot() to agent config routes Frontend: - api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods - SkillEditor.svelte: file tree + text editor + version/description form - skills/+page.svelte: skill list, create form (generates SKILL.md template), per-skill editor - layout: add 技能 nav item ADR-0018: update Decision to reflect web surface joining host-console CLI in the shared content-addressed ingestion pipeline. Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web installation is one implementation of it.
554 lines
21 KiB
TypeScript
554 lines
21 KiB
TypeScript
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<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 }> {
|
|
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<readonly SkillFileOutput[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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),
|
|
};
|
|
}
|