nexus/server/src/services/skill-registry.ts
Mikkel Georgsen b52f5a8adf feat(12-01): ratings routes, community ratings in fetcher, list/getById JOIN, heartbeat hook
- Add POST/GET /skill-registry/skills/:sourceId/:slug/ratings routes
- Import skillRatingService in skill-registry routes
- Add upsertCommunityRatingsStub() in fetcher, called after each skill upsert
- Import communityRatings from schema in fetcher
- Update list() and getById() in skill-registry.ts to LEFT JOIN communityRatings
- Include averageRating, ratingCount, taskCount, avgCostUsd, lastUsedAt in SkillListItem
- Add agentSkills usage aggregation via LEFT JOIN + SUM/AVG/MAX
- Add fire-and-forget recordUsageForAgent call in heartbeat after finalizeAgentStatus
- Dynamic import keeps skill-registry-ratings off critical startup path
- All 44 skill-registry tests pass, full server suite (536) green
2026-04-01 04:08:18 +02:00

202 lines
7.7 KiB
TypeScript

import { eq, isNull, and, desc, sql } from "drizzle-orm";
import { cp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { getSkillRegistryDb } from "./skill-registry-db.js";
import { skills, skillVersions, skillFiles, communityRatings, agentSkills } from "./skill-registry-schema.js";
import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
import { resolveSkillCacheDir } from "../home-paths.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type SkillRow = typeof skills.$inferSelect;
type VersionRow = typeof skillVersions.$inferSelect;
/** Extended skill list item with community rating and usage stats from JOINs */
type SkillListItem = SkillRow & {
averageRating: number | null;
ratingCount: number | null;
taskCount: number | null;
avgCostUsd: number | null;
lastUsedAt: number | null;
};
type InstallResult =
| { type: "installed"; skillId: string; versionId: string; targetDir: string }
| { type: "pending_plugin_install"; command: string; skillId: string; versionId: string };
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Skill registry service factory.
* Manages its own libSQL database (does not accept a Postgres db param).
* Use `getSkillRegistryDb()` for all persistence.
*/
export function skillRegistryService() {
return {
async list(opts?: { includeRemoved?: boolean }): Promise<SkillListItem[]> {
const db = await getSkillRegistryDb();
const query = db
.select({
// All skills columns
id: skills.id,
sourceId: skills.sourceId,
name: skills.name,
description: skills.description,
sourceUrl: skills.sourceUrl,
activeVersionId: skills.activeVersionId,
removedAt: skills.removedAt,
createdAt: skills.createdAt,
updatedAt: skills.updatedAt,
// Community rating fields from LEFT JOIN
averageRating: communityRatings.averageRating,
ratingCount: communityRatings.ratingCount,
// Aggregated usage stats across all agents
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
})
.from(skills)
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
.groupBy(skills.id, communityRatings.id);
if (opts?.includeRemoved) {
return query as Promise<SkillListItem[]>;
}
return query.where(isNull(skills.removedAt)) as Promise<SkillListItem[]>;
},
async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise<SkillListItem | undefined> {
const db = await getSkillRegistryDb();
const conditions: Parameters<typeof and>[0][] = [eq(skills.id, skillId)];
if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt));
const rows = await db
.select({
id: skills.id,
sourceId: skills.sourceId,
name: skills.name,
description: skills.description,
sourceUrl: skills.sourceUrl,
activeVersionId: skills.activeVersionId,
removedAt: skills.removedAt,
createdAt: skills.createdAt,
updatedAt: skills.updatedAt,
averageRating: communityRatings.averageRating,
ratingCount: communityRatings.ratingCount,
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
})
.from(skills)
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
.groupBy(skills.id, communityRatings.id)
.where(and(...conditions));
return rows[0] as SkillListItem | undefined;
},
async getVersions(skillId: string): Promise<VersionRow[]> {
const db = await getSkillRegistryDb();
return db.select().from(skillVersions).where(eq(skillVersions.skillId, skillId));
},
async install(skillId: string, agentSkillsDir: string): Promise<InstallResult> {
const db = await getSkillRegistryDb();
const skill = await this.getById(skillId);
if (!skill) throw new Error(`Skill not found: ${skillId}`);
// Get latest version (most recently fetched)
const versions = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.skillId, skillId))
.orderBy(desc(skillVersions.fetchedAt));
const latest = versions[0];
if (!latest) throw new Error(`No versions found for skill: ${skillId}`);
// Check if this is a marketplace plugin — identified by any file having kind="plugin"
const files = await db
.select()
.from(skillFiles)
.where(eq(skillFiles.versionId, latest.id));
const isPlugin = files.some((f) => f.kind === "plugin");
if (isPlugin) {
// Return pending plugin install command instead of copying files
const slug = skillId.split("/").pop() ?? skillId;
return {
type: "pending_plugin_install" as const,
command: `/plugin install ${slug}@marketplace`,
skillId,
versionId: latest.id,
};
}
// Copy cached files to agent skills dir
const cacheDir = latest.cacheDir ?? resolveSkillCacheDir(skillId, latest.id);
const slug = skillId.split("/").pop() ?? skillId;
const targetDir = path.join(agentSkillsDir, slug);
await mkdir(targetDir, { recursive: true });
await cp(cacheDir, targetDir, { recursive: true });
// Update active version
await db
.update(skills)
.set({ activeVersionId: latest.id, updatedAt: Date.now() })
.where(eq(skills.id, skillId));
return {
type: "installed" as const,
skillId,
versionId: latest.id,
targetDir,
};
},
async uninstall(skillId: string): Promise<void> {
const db = await getSkillRegistryDb();
await db
.update(skills)
.set({ removedAt: Date.now(), updatedAt: Date.now() })
.where(eq(skills.id, skillId));
},
async rollback(skillId: string, versionId: string, agentSkillsDir: string): Promise<void> {
const db = await getSkillRegistryDb();
const versionRows = await db
.select()
.from(skillVersions)
.where(eq(skillVersions.id, versionId));
const version = versionRows[0];
if (!version) throw new Error(`Version not found: ${versionId}`);
const cacheDir = version.cacheDir ?? resolveSkillCacheDir(skillId, versionId);
const slug = skillId.split("/").pop() ?? skillId;
const targetDir = path.join(agentSkillsDir, slug);
// Remove current files, restore from cache
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
await cp(cacheDir, targetDir, { recursive: true });
// Update active version to the rolled-back version
await db
.update(skills)
.set({ activeVersionId: versionId, updatedAt: Date.now() })
.where(eq(skills.id, skillId));
},
async fetchAll(
sources?: SkillSourceConfig[],
): Promise<{ fetched: number; errors: string[] }> {
return fetchAllSources(sources);
},
};
}