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:
parent
e7e0ab7b87
commit
b455942e79
1 changed files with 500 additions and 0 deletions
500
server/src/services/skill-registry-groups.ts
Normal file
500
server/src/services/skill-registry-groups.ts
Normal 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!;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue