forked from EduCraft/curriculum-project-hub
feat(admin): web-based skill management with file editor
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.
This commit is contained in:
+157
-16
@@ -1,7 +1,7 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||
import { importSkillDirectory } from "./skillStore.js";
|
||||
import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
|
||||
|
||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
@@ -88,38 +88,179 @@ export class OrganizationAgentConfiguration {
|
||||
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");
|
||||
}
|
||||
const storeRoot = this.requireSkillStoreRoot();
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const version = nonEmpty(input.version, "skill version");
|
||||
const imported = await importSkillDirectory({
|
||||
sourceDir: input.sourceDir,
|
||||
storeRoot: this.skillStoreRoot,
|
||||
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: imported.name } },
|
||||
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: imported.name,
|
||||
name: input.imported.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
name: input.imported.name,
|
||||
version: input.version,
|
||||
description: input.imported.description ?? null,
|
||||
contentDigest: input.imported.contentDigest,
|
||||
},
|
||||
update: {
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
version: input.version,
|
||||
description: input.imported.description ?? null,
|
||||
contentDigest: input.imported.contentDigest,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: {
|
||||
@@ -139,10 +280,10 @@ export class OrganizationAgentConfiguration {
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.installed",
|
||||
action: input.action,
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
version,
|
||||
version: input.version,
|
||||
contentDigest: skill.contentDigest,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user