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:
2026-07-19 13:49:28 +08:00
parent 7f09fb1f13
commit 854c6189bb
16 changed files with 1367 additions and 188 deletions
@@ -118,6 +118,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });