forked from EduCraft/curriculum-project-hub
feat(hub): org-scoped agent role/skill folder tree (ADR-0028)
Add a shared transparent OrganizationAgentConfigFolder tree for grouping agent roles and skills in the admin UI without affecting identity, bindings, run loading, or slash commands.
This commit is contained in:
@@ -15,6 +15,10 @@
|
||||
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
||||
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
||||
* / 16-byte limits, symlink rejection).
|
||||
*
|
||||
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
|
||||
* roles and skills for management-surface grouping. Folder endpoints never
|
||||
* touch session state — assignment is a label-class change (ADR-0017).
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
|
||||
|
||||
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
|
||||
return { folders };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; parentId?: unknown };
|
||||
if (typeof body.name !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "name is required" },
|
||||
});
|
||||
}
|
||||
const folder = await agentConfig.createFolder({
|
||||
organizationId: auth.organization.id,
|
||||
name: body.name,
|
||||
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
|
||||
});
|
||||
return reply.status(201).send(folder);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { name?: unknown; parentId?: unknown };
|
||||
const folder = await agentConfig.updateFolder({
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
...(typeof body.name === "string" ? { name: body.name } : {}),
|
||||
...(body.parentId === null || typeof body.parentId === "string"
|
||||
? { parentId: body.parentId as string | null }
|
||||
: {}),
|
||||
});
|
||||
return folder;
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
await agentConfig.deleteFolder({
|
||||
organizationId: auth.organization.id,
|
||||
folderId,
|
||||
});
|
||||
return { deleted: true };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Folder assignment is a label-class change (ADR-0017): these endpoints
|
||||
// never archive Agent sessions (ADR-0028).
|
||||
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { folderId?: unknown };
|
||||
if (body.folderId !== null && typeof body.folderId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "folderId must be a string or null" },
|
||||
});
|
||||
}
|
||||
await agentConfig.setRoleFolder({
|
||||
organizationId: auth.organization.id,
|
||||
roleId,
|
||||
folderId: body.folderId as string | null,
|
||||
});
|
||||
return { folderId: body.folderId as string | null };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { folderId?: unknown };
|
||||
if (body.folderId !== null && typeof body.folderId !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "folderId must be a string or null" },
|
||||
});
|
||||
}
|
||||
await agentConfig.setSkillFolder({
|
||||
organizationId: auth.organization.id,
|
||||
name,
|
||||
folderId: body.folderId as string | null,
|
||||
});
|
||||
return { folderId: body.folderId as string | null };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly skillNames: readonly string[];
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillRow {
|
||||
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly boundRoleIds: readonly string[];
|
||||
readonly folderId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-0028 transparent folder node of the org's shared agent-config folder
|
||||
* tree. Grouping only: never part of skill/role identity or run resolution.
|
||||
*/
|
||||
export interface AgentConfigFolderRow {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly parentId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
|
||||
createdAt: skill.createdAt.toISOString(),
|
||||
updatedAt: skill.updatedAt.toISOString(),
|
||||
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
folderId: skill.folderId,
|
||||
}));
|
||||
}
|
||||
|
||||
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
|
||||
where: { organizationId: input.organizationId },
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
return folders;
|
||||
}
|
||||
|
||||
async createFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly parentId?: string | undefined;
|
||||
}): Promise<AgentConfigFolderRow> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const name = nonEmpty(input.name, "folder name");
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (input.parentId !== undefined) {
|
||||
await requireFolder(tx, input.organizationId, input.parentId);
|
||||
}
|
||||
const folder = await tx.organizationAgentConfigFolder.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
name,
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.created",
|
||||
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
|
||||
},
|
||||
});
|
||||
return folder;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename and/or move a folder inside the same Organization tree. Moving is
|
||||
* rejected when the target parent is the folder itself or one of its
|
||||
* descendants (would create a cycle).
|
||||
*/
|
||||
async updateFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string;
|
||||
readonly name?: string | undefined;
|
||||
readonly parentId?: string | null | undefined;
|
||||
}): Promise<AgentConfigFolderRow> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const folder = await requireFolder(tx, input.organizationId, input.folderId);
|
||||
if (input.parentId !== undefined && input.parentId !== null) {
|
||||
if (input.parentId === folder.id) {
|
||||
throw new Error("folder cannot be its own parent");
|
||||
}
|
||||
await requireFolder(tx, input.organizationId, input.parentId);
|
||||
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
|
||||
UNION ALL
|
||||
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
|
||||
JOIN descendants parent ON child."parentId" = parent."id"
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
|
||||
`);
|
||||
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
|
||||
}
|
||||
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
|
||||
const updated = await tx.organizationAgentConfigFolder.update({
|
||||
where: { id: folder.id },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.updated",
|
||||
metadata: {
|
||||
folderId: folder.id,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder. Refused while the folder still has child folders, roles
|
||||
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
|
||||
* rule is needed).
|
||||
*/
|
||||
async deleteFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly folderId: string;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const folder = await requireFolder(tx, input.organizationId, input.folderId);
|
||||
const childFolders = await tx.organizationAgentConfigFolder.count({
|
||||
where: { parentId: folder.id },
|
||||
});
|
||||
if (childFolders > 0) {
|
||||
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
|
||||
}
|
||||
const skills = await tx.organizationAgentSkill.count({
|
||||
where: { organizationId: input.organizationId, folderId: folder.id },
|
||||
});
|
||||
const roles = await tx.organizationAgentRole.count({
|
||||
where: { organizationId: input.organizationId, folderId: folder.id },
|
||||
});
|
||||
if (skills > 0 || roles > 0) {
|
||||
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
|
||||
}
|
||||
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_config_folder.deleted",
|
||||
metadata: { folderId: folder.id, name: folder.name },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
|
||||
* a label-class change in the ADR-0017 sense — the execution surface is
|
||||
* untouched, so no session archival (ADR-0028).
|
||||
*/
|
||||
async setSkillFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly name: string;
|
||||
readonly folderId: string | null;
|
||||
}): 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 },
|
||||
});
|
||||
if (skill === null || skill.disabledAt !== null) {
|
||||
throw new Error(`active skill not found in organization: ${input.name}`);
|
||||
}
|
||||
if (input.folderId !== null) {
|
||||
await requireFolder(tx, input.organizationId, input.folderId);
|
||||
}
|
||||
await tx.organizationAgentSkill.update({
|
||||
where: { id: skill.id },
|
||||
data: { folderId: input.folderId },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.folder_set",
|
||||
metadata: { name: input.name, folderId: input.folderId },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a role to a folder (or unfile it with `folderId: null`). Same
|
||||
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
|
||||
*/
|
||||
async setRoleFolder(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly folderId: string | null;
|
||||
}): Promise<void> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
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}`);
|
||||
}
|
||||
if (input.folderId !== null) {
|
||||
await requireFolder(tx, input.organizationId, input.folderId);
|
||||
}
|
||||
await tx.organizationAgentRole.update({
|
||||
where: { id: role.id },
|
||||
data: { folderId: input.folderId },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.folder_set",
|
||||
metadata: { roleId: input.roleId, folderId: input.folderId },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async installSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly sourceDir: string;
|
||||
@@ -513,6 +729,20 @@ async function invalidateRoleSessionClaudeIds(
|
||||
}
|
||||
}
|
||||
|
||||
async function requireFolder(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
folderId: string,
|
||||
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
|
||||
const folder = await tx.organizationAgentConfigFolder.findFirst({
|
||||
where: { id: folderId, organizationId },
|
||||
select: { id: true, name: true, parentId: true },
|
||||
});
|
||||
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
|
||||
return folder;
|
||||
}
|
||||
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
@@ -543,6 +773,7 @@ function toRoleRow(role: {
|
||||
readonly disabledAt: Date | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
readonly folderId: string | null;
|
||||
readonly skillBindings: ReadonlyArray<{
|
||||
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
|
||||
}>;
|
||||
@@ -562,5 +793,6 @@ function toRoleRow(role: {
|
||||
skillNames: role.skillBindings
|
||||
.filter((binding) => binding.skill.disabledAt === null)
|
||||
.map((binding) => binding.skill.name),
|
||||
folderId: role.folderId,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user