- 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
500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
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!;
|
|
},
|
|
};
|
|
}
|