From 40165ffae165808c05d9cb3de5377f1e166bbf29 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 03:25:26 +0200 Subject: [PATCH] feat(11-02): skillGroupService() with CRUD, membership, inheritance, assignment, import/export - Group CRUD: listGroups, getGroup, createGroup, updateGroup, deleteGroup (guards built-in) - Member management: addMember, removeMember, listMembers - Inheritance: addParent (with cycle detection BFS), removeParent, listParents - resolveEffectiveSkills: BFS walk with visited-set guard for cycle safety - assignGroup: installs all effective skills, tracks in agent_skills, returns installed/skipped/pendingPlugin - removeGroup: set-difference uninstall with fs.rm() for file removal (not skillRegistryService.uninstall) - listAgentGroups, listAgentSkills, getAgentEffectiveSkills - exportGroup / importGroup: GroupExport v1 JSON with cycle check on import --- server/src/services/skill-registry-groups.ts | 500 +++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 server/src/services/skill-registry-groups.ts diff --git a/server/src/services/skill-registry-groups.ts b/server/src/services/skill-registry-groups.ts new file mode 100644 index 00000000..e6c0e2af --- /dev/null +++ b/server/src/services/skill-registry-groups.ts @@ -0,0 +1,500 @@ +import { eq, and, inArray } from "drizzle-orm"; +import { rm } from "node:fs/promises"; +import path from "node:path"; +import { getSkillRegistryDb } from "./skill-registry-db.js"; +import { + skillGroups, + skillGroupMembers, + skillGroupInheritance, + agentSkillGroups, + agentSkills, +} from "./skill-registry-schema.js"; +import { skillRegistryService } from "./skill-registry.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type GroupRow = typeof skillGroups.$inferSelect; +type MemberRow = typeof skillGroupMembers.$inferSelect; + +type GroupExport = { + version: "1"; + group: { + id: string; + name: string; + description: string | null; + members: string[]; + parents: string[]; + }; +}; + +type AssignResult = { installed: string[]; skipped: string[]; pendingPlugin: string[] }; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Skill group service factory. + * Manages its own libSQL database (does not accept a Postgres db param). + * Use `getSkillRegistryDb()` for all persistence. + */ +export function skillGroupService() { + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * BFS cycle detection: would adding parentGroupId as a parent of childGroupId + * create a cycle? Returns true if childGroupId is reachable from parentGroupId + * by walking up the inheritance chain. + */ + async function wouldCreateCycle( + childGroupId: string, + parentGroupId: string, + ): Promise { + const db = await getSkillRegistryDb(); + const visited = new Set(); + const queue: string[] = [parentGroupId]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === childGroupId) return true; + if (visited.has(current)) continue; + visited.add(current); + + // Walk up: find parents of current + const rows = await db + .select() + .from(skillGroupInheritance) + .where(eq(skillGroupInheritance.childGroupId, current)); + for (const row of rows) { + if (!visited.has(row.parentGroupId)) { + queue.push(row.parentGroupId); + } + } + } + + return false; + } + + return { + // ------------------------------------------------------------------------- + // Group CRUD + // ------------------------------------------------------------------------- + + async listGroups(): Promise { + const db = await getSkillRegistryDb(); + // Order: built-in first, then alphabetical by name + const rows = await db.select().from(skillGroups); + return rows.sort((a, b) => { + if (a.isBuiltin !== b.isBuiltin) return b.isBuiltin - a.isBuiltin; + return a.name.localeCompare(b.name); + }); + }, + + async getGroup(groupId: string): Promise { + const db = await getSkillRegistryDb(); + const rows = await db + .select() + .from(skillGroups) + .where(eq(skillGroups.id, groupId)); + return rows[0]; + }, + + async createGroup(input: { + name: string; + description?: string; + }): Promise { + const db = await getSkillRegistryDb(); + const id = `custom/${input.name.toLowerCase().replace(/\s+/g, "-")}`; + const now = Date.now(); + const row: typeof skillGroups.$inferInsert = { + id, + name: input.name, + description: input.description ?? null, + isBuiltin: 0, + createdAt: now, + updatedAt: now, + }; + await db.insert(skillGroups).values(row); + const inserted = await db + .select() + .from(skillGroups) + .where(eq(skillGroups.id, id)); + return inserted[0]!; + }, + + async updateGroup( + groupId: string, + patch: { name?: string; description?: string }, + ): Promise { + const db = await getSkillRegistryDb(); + const existing = await this.getGroup(groupId); + if (!existing) throw new Error("Group not found"); + + const updates: Partial = { + updatedAt: Date.now(), + }; + if (patch.name !== undefined) updates.name = patch.name; + if (patch.description !== undefined) updates.description = patch.description; + + await db + .update(skillGroups) + .set(updates) + .where(eq(skillGroups.id, groupId)); + + const updated = await db + .select() + .from(skillGroups) + .where(eq(skillGroups.id, groupId)); + return updated[0]!; + }, + + async deleteGroup(groupId: string): Promise { + const db = await getSkillRegistryDb(); + const existing = await this.getGroup(groupId); + if (!existing) throw new Error("Group not found"); + if (existing.isBuiltin === 1) + throw new Error("Cannot delete built-in group"); + + // Remove all membership rows + await db + .delete(skillGroupMembers) + .where(eq(skillGroupMembers.groupId, groupId)); + + // Remove all inheritance rows (as parent or child) + await db + .delete(skillGroupInheritance) + .where(eq(skillGroupInheritance.childGroupId, groupId)); + await db + .delete(skillGroupInheritance) + .where(eq(skillGroupInheritance.parentGroupId, groupId)); + + // Remove all agent assignments + await db + .delete(agentSkillGroups) + .where(eq(agentSkillGroups.groupId, groupId)); + + // Remove group itself + await db.delete(skillGroups).where(eq(skillGroups.id, groupId)); + }, + + // ------------------------------------------------------------------------- + // Member management + // ------------------------------------------------------------------------- + + async addMember(groupId: string, skillId: string): Promise { + const db = await getSkillRegistryDb(); + await db + .insert(skillGroupMembers) + .values({ groupId, skillId, addedAt: Date.now() }) + .onConflictDoNothing(); + }, + + async removeMember(groupId: string, skillId: string): Promise { + const db = await getSkillRegistryDb(); + await db + .delete(skillGroupMembers) + .where( + and( + eq(skillGroupMembers.groupId, groupId), + eq(skillGroupMembers.skillId, skillId), + ), + ); + }, + + async listMembers(groupId: string): Promise { + const db = await getSkillRegistryDb(); + return db + .select() + .from(skillGroupMembers) + .where(eq(skillGroupMembers.groupId, groupId)); + }, + + // ------------------------------------------------------------------------- + // Inheritance management + // ------------------------------------------------------------------------- + + async addParent( + childGroupId: string, + parentGroupId: string, + ): Promise { + const db = await getSkillRegistryDb(); + const cycle = await wouldCreateCycle(childGroupId, parentGroupId); + if (cycle) + throw new Error("Adding this parent would create a cycle"); + + await db + .insert(skillGroupInheritance) + .values({ childGroupId, parentGroupId }) + .onConflictDoNothing(); + }, + + async removeParent( + childGroupId: string, + parentGroupId: string, + ): Promise { + const db = await getSkillRegistryDb(); + await db + .delete(skillGroupInheritance) + .where( + and( + eq(skillGroupInheritance.childGroupId, childGroupId), + eq(skillGroupInheritance.parentGroupId, parentGroupId), + ), + ); + }, + + async listParents(groupId: string): Promise { + const db = await getSkillRegistryDb(); + const rows = await db + .select() + .from(skillGroupInheritance) + .where(eq(skillGroupInheritance.childGroupId, groupId)); + return rows.map((r) => r.parentGroupId); + }, + + // ------------------------------------------------------------------------- + // Effective skill resolution + // ------------------------------------------------------------------------- + + /** + * BFS walk through the group inheritance tree. + * Collects all direct member skills from the group and all parent groups. + * Uses a visited set to handle cycles safely. + */ + async resolveEffectiveSkills(groupId: string): Promise { + const db = await getSkillRegistryDb(); + const visited = new Set(); + const skillIds = new Set(); + + async function walk(gid: string): Promise { + if (visited.has(gid)) return; + visited.add(gid); + + // Collect direct members + const members = await db + .select() + .from(skillGroupMembers) + .where(eq(skillGroupMembers.groupId, gid)); + for (const m of members) skillIds.add(m.skillId); + + // Recurse into parents + const parents = await db + .select() + .from(skillGroupInheritance) + .where(eq(skillGroupInheritance.childGroupId, gid)); + for (const p of parents) await walk(p.parentGroupId); + } + + await walk(groupId); + return Array.from(skillIds); + }, + + // ------------------------------------------------------------------------- + // Agent assignment + // ------------------------------------------------------------------------- + + async assignGroup( + groupId: string, + agentId: string, + agentSkillsDir: string, + ): Promise { + const db = await getSkillRegistryDb(); + const installed: string[] = []; + const skipped: string[] = []; + const pendingPlugin: string[] = []; + + // Idempotent assignment + await db + .insert(agentSkillGroups) + .values({ agentId, groupId, assignedAt: Date.now() }) + .onConflictDoNothing(); + + const skillIds = await this.resolveEffectiveSkills(groupId); + const svc = skillRegistryService(); + + for (const skillId of skillIds) { + try { + const result = await svc.install(skillId, agentSkillsDir); + // Record in agent_skills + await db + .insert(agentSkills) + .values({ agentId, skillId, installedAt: Date.now() }) + .onConflictDoNothing(); + + if (result.type === "installed") { + installed.push(skillId); + } else { + // pending_plugin_install + pendingPlugin.push(result.command); + } + } catch (err) { + // Don't block the entire assignment if one skill fails + skipped.push(skillId); + } + } + + return { installed, skipped, pendingPlugin }; + }, + + async removeGroup( + groupId: string, + agentId: string, + agentSkillsDir: string, + ): Promise { + const db = await getSkillRegistryDb(); + + // Remove group assignment + await db + .delete(agentSkillGroups) + .where( + and( + eq(agentSkillGroups.agentId, agentId), + eq(agentSkillGroups.groupId, groupId), + ), + ); + + // Get remaining groups + const remainingRows = await db + .select() + .from(agentSkillGroups) + .where(eq(agentSkillGroups.agentId, agentId)); + + // Union all skills still required by remaining groups + const stillNeeded = new Set(); + for (const row of remainingRows) { + const effective = await this.resolveEffectiveSkills(row.groupId); + for (const sid of effective) stillNeeded.add(sid); + } + + // Individually installed skills (not from a group) — these should be preserved + const individualRows = await db + .select() + .from(agentSkills) + .where(eq(agentSkills.agentId, agentId)); + const individualSkills = new Set(individualRows.map((r) => r.skillId)); + + // Find skills that were contributed by the removed group + const removedGroupSkills = await this.resolveEffectiveSkills(groupId); + + for (const skillId of removedGroupSkills) { + // Skip if still needed by another group or individually installed + if (stillNeeded.has(skillId) || individualSkills.has(skillId)) continue; + + // Remove files from agent skills directory + const slug = skillId.split("/").pop() ?? skillId; + await rm(path.join(agentSkillsDir, slug), { + recursive: true, + force: true, + }); + + // Remove from agent_skills if present + await db + .delete(agentSkills) + .where( + and( + eq(agentSkills.agentId, agentId), + eq(agentSkills.skillId, skillId), + ), + ); + } + }, + + async listAgentGroups(agentId: string): Promise { + const db = await getSkillRegistryDb(); + const assignments = await db + .select() + .from(agentSkillGroups) + .where(eq(agentSkillGroups.agentId, agentId)); + + if (assignments.length === 0) return []; + + const groupIds = assignments.map((a) => a.groupId); + return db + .select() + .from(skillGroups) + .where(inArray(skillGroups.id, groupIds)); + }, + + async listAgentSkills(agentId: string): Promise { + const db = await getSkillRegistryDb(); + const rows = await db + .select() + .from(agentSkills) + .where(eq(agentSkills.agentId, agentId)); + return rows.map((r) => r.skillId); + }, + + async getAgentEffectiveSkills(agentId: string): Promise { + const groups = await this.listAgentGroups(agentId); + const union = new Set(); + for (const group of groups) { + const skills = await this.resolveEffectiveSkills(group.id); + for (const s of skills) union.add(s); + } + return Array.from(union); + }, + + // ------------------------------------------------------------------------- + // Import / Export + // ------------------------------------------------------------------------- + + async exportGroup(groupId: string): Promise { + const group = await this.getGroup(groupId); + if (!group) throw new Error("Group not found"); + + const memberRows = await this.listMembers(groupId); + const parentIds = await this.listParents(groupId); + + return { + version: "1", + group: { + id: group.id, + name: group.name, + description: group.description, + members: memberRows.map((m) => m.skillId), + parents: parentIds, + }, + }; + }, + + async importGroup(data: GroupExport): Promise { + if (data.version !== "1") throw new Error("Unsupported export version"); + + const existing = await this.getGroup(data.group.id); + if (existing) { + throw new Error( + `A group with id "${data.group.id}" already exists. Rename the group before importing.`, + ); + } + + // Create the group + const db = await getSkillRegistryDb(); + const now = Date.now(); + await db.insert(skillGroups).values({ + id: data.group.id, + name: data.group.name, + description: data.group.description, + isBuiltin: 0, + createdAt: now, + updatedAt: now, + }); + + // Insert members + for (const skillId of data.group.members) { + await this.addMember(data.group.id, skillId); + } + + // Insert parents (with cycle check via addParent) + for (const parentId of data.group.parents) { + await this.addParent(data.group.id, parentId); + } + + const newGroup = await this.getGroup(data.group.id); + return newGroup!; + }, + }; +}