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
This commit is contained in:
Mikkel Georgsen 2026-04-01 03:25:26 +02:00
parent 43abc69fb5
commit b918c5e809

View file

@ -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<boolean> {
const db = await getSkillRegistryDb();
const visited = new Set<string>();
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<GroupRow[]> {
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<GroupRow | undefined> {
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<GroupRow> {
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<GroupRow> {
const db = await getSkillRegistryDb();
const existing = await this.getGroup(groupId);
if (!existing) throw new Error("Group not found");
const updates: Partial<typeof skillGroups.$inferInsert> = {
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<void> {
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<void> {
const db = await getSkillRegistryDb();
await db
.insert(skillGroupMembers)
.values({ groupId, skillId, addedAt: Date.now() })
.onConflictDoNothing();
},
async removeMember(groupId: string, skillId: string): Promise<void> {
const db = await getSkillRegistryDb();
await db
.delete(skillGroupMembers)
.where(
and(
eq(skillGroupMembers.groupId, groupId),
eq(skillGroupMembers.skillId, skillId),
),
);
},
async listMembers(groupId: string): Promise<MemberRow[]> {
const db = await getSkillRegistryDb();
return db
.select()
.from(skillGroupMembers)
.where(eq(skillGroupMembers.groupId, groupId));
},
// -------------------------------------------------------------------------
// Inheritance management
// -------------------------------------------------------------------------
async addParent(
childGroupId: string,
parentGroupId: string,
): Promise<void> {
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<void> {
const db = await getSkillRegistryDb();
await db
.delete(skillGroupInheritance)
.where(
and(
eq(skillGroupInheritance.childGroupId, childGroupId),
eq(skillGroupInheritance.parentGroupId, parentGroupId),
),
);
},
async listParents(groupId: string): Promise<string[]> {
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<string[]> {
const db = await getSkillRegistryDb();
const visited = new Set<string>();
const skillIds = new Set<string>();
async function walk(gid: string): Promise<void> {
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<AssignResult> {
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<void> {
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<string>();
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<GroupRow[]> {
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<string[]> {
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<string[]> {
const groups = await this.listAgentGroups(agentId);
const union = new Set<string>();
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<GroupExport> {
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<GroupRow> {
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!;
},
};
}