forked from EduCraft/curriculum-project-hub
c9adf83e5c
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.
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
/**
|
|
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
|
*
|
|
* Surface for browsing and editing Organization-scoped Agent roles, the skills
|
|
* bound to them, and the model picker. The model list is **derived** from the
|
|
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
|
|
* no separate model table to maintain. When no ACTIVE provider exists, the
|
|
* route falls back to the env-default model registry so the admin surface
|
|
* remains usable.
|
|
*
|
|
* Skill management (create, read, edit, disable) is performed in-band through
|
|
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
|
* content-addressed persistent store used by the host-console CLI. All skill
|
|
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
|
|
* `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";
|
|
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
|
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
|
|
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
|
import { handleRouteError } from "../errors.js";
|
|
|
|
export interface AgentConfigRouteConfig {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
readonly skillStoreRoot: string;
|
|
readonly secretEnvelope: LocalSecretEnvelope;
|
|
}
|
|
|
|
export async function registerAgentConfigRoutes(
|
|
app: FastifyInstance,
|
|
config: AgentConfigRouteConfig,
|
|
): Promise<void> {
|
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
|
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
|
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
|
|
|
|
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
|
|
return { roles };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-roles/:roleId", 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 {
|
|
label?: unknown;
|
|
defaultModel?: unknown;
|
|
systemPrompt?: unknown;
|
|
tools?: unknown;
|
|
sortOrder?: unknown;
|
|
isDefault?: unknown;
|
|
};
|
|
if (typeof body.label !== "string" || body.label.trim() === "") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "label is required" },
|
|
});
|
|
}
|
|
const role = await agentConfig.upsertRole({
|
|
organizationId: auth.organization.id,
|
|
roleId,
|
|
label: body.label,
|
|
...(body.defaultModel === null || typeof body.defaultModel === "string"
|
|
? { defaultModel: body.defaultModel as string | null }
|
|
: {}),
|
|
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
|
|
? { systemPrompt: body.systemPrompt as string | null }
|
|
: {}),
|
|
...(body.tools === null || Array.isArray(body.tools)
|
|
? { tools: body.tools as readonly string[] | null }
|
|
: {}),
|
|
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
|
|
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
|
|
});
|
|
return role;
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", 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 { skillNames?: unknown };
|
|
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "skillNames must be a string array" },
|
|
});
|
|
}
|
|
await agentConfig.setRoleSkills({
|
|
organizationId: auth.organization.id,
|
|
roleId,
|
|
skillNames: body.skillNames as readonly string[],
|
|
});
|
|
return { skillNames: body.skillNames as string[] };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
|
return { skills };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-skills/:name/files", 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 skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
|
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
|
|
if (skill === undefined) {
|
|
return reply.status(404).send({
|
|
error: { code: "not_found", message: `skill not found: ${name}` },
|
|
});
|
|
}
|
|
const files = await agentConfig.readSkillFiles({
|
|
organizationId: auth.organization.id,
|
|
contentDigest: skill.contentDigest,
|
|
});
|
|
return { files };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-skills/:name", 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 { version?: unknown; files?: unknown };
|
|
if (typeof body.version !== "string" || body.version.trim() === "") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "version is required" },
|
|
});
|
|
}
|
|
if (!Array.isArray(body.files) || body.files.length === 0) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "files must be a non-empty array" },
|
|
});
|
|
}
|
|
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
|
for (const entry of body.files) {
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "each file must be an object" },
|
|
});
|
|
}
|
|
const e = entry as { path?: unknown; content?: unknown };
|
|
if (typeof e.path !== "string" || typeof e.content !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "each file needs string path and content" },
|
|
});
|
|
}
|
|
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
|
|
}
|
|
const result = await agentConfig.installSkillFromFiles({
|
|
organizationId: auth.organization.id,
|
|
files: skillFiles,
|
|
version: body.version,
|
|
});
|
|
// The manifest name is authoritative — reject if it doesn't match the
|
|
// URL param, so clients can't silently rename a skill under a different
|
|
// route key.
|
|
if (result.name !== name) {
|
|
return reply.status(400).send({
|
|
error: {
|
|
code: "bad_request",
|
|
message: `skill manifest name "${result.name}" does not match route "${name}"`,
|
|
},
|
|
});
|
|
}
|
|
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch("/api/org/:orgSlug/agent-skills/:name", 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 { description?: unknown; disabled?: unknown };
|
|
if (body.disabled === true) {
|
|
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
|
|
return { disabled: true };
|
|
}
|
|
if (typeof body.description === "string") {
|
|
await agentConfig.updateSkillDescription({
|
|
organizationId: auth.organization.id,
|
|
name,
|
|
description: body.description,
|
|
});
|
|
return { updated: true };
|
|
}
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "provide description or disabled: true" },
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const providerModels = await modelCatalog.listModels(auth.organization.id);
|
|
// Fall back to env-default registry when the org has no ACTIVE provider
|
|
// (or the provider API is unreachable on first load).
|
|
const models = providerModels.length > 0
|
|
? providerModels
|
|
: createDefaultModelRegistry(process.env).listModels();
|
|
return { models };
|
|
} catch (err) {
|
|
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);
|
|
}
|
|
});
|
|
}
|