From b52f5a8adf75a0883cbd1d53c9bbc349ca15478c Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 04:08:18 +0200 Subject: [PATCH] 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 --- server/src/routes/skill-registry.ts | 23 ++++++ server/src/services/heartbeat.ts | 5 ++ server/src/services/skill-registry-fetcher.ts | 36 ++++++++- server/src/services/skill-registry.ts | 73 +++++++++++++++++-- 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/server/src/routes/skill-registry.ts b/server/src/routes/skill-registry.ts index ec266509..f0ccb948 100644 --- a/server/src/routes/skill-registry.ts +++ b/server/src/routes/skill-registry.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { skillRegistryService } from "../services/skill-registry.js"; +import { skillRatingService } from "../services/skill-registry-ratings.js"; import { assertBoard } from "./authz.js"; /** @@ -59,6 +60,28 @@ export function skillRegistryRoutes(): Router { res.json({ ok: true }); }); + // Submit a personal rating for a skill + router.post("/skill-registry/skills/:sourceId/:slug/ratings", async (req, res) => { + assertBoard(req); + const skillId = `${req.params.sourceId}/${req.params.slug}`; + const { stars, versionId, note } = req.body as { stars: number; versionId?: string; note?: string }; + if (typeof stars !== "number" || stars < 1 || stars > 5) { + return res.status(400).json({ error: "stars must be a number between 1 and 5" }); + } + const ratingSvc = skillRatingService(); + await ratingSvc.rate({ skillId, versionId: versionId ?? null, stars, note: note ?? null }); + res.json({ ok: true }); + }); + + // Get personal ratings for a skill + router.get("/skill-registry/skills/:sourceId/:slug/ratings", async (req, res) => { + assertBoard(req); + const skillId = `${req.params.sourceId}/${req.params.slug}`; + const ratingSvc = skillRatingService(); + const ratings = await ratingSvc.getRatings(skillId); + res.json(ratings); + }); + // Get a single skill by id router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => { assertBoard(req); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 06ad9f2a..00b1d1e7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2717,6 +2717,11 @@ export function heartbeatService(db: Db) { } } await finalizeAgentStatus(agent.id, outcome); + if (outcome === "succeeded") { + void import("./skill-registry-ratings.js").then(({ skillRatingService }) => + skillRatingService().recordUsageForAgent(agent.id, normalizedUsage?.totalCostUsd ?? null) + ).catch((err) => logger.warn({ err, agentId: agent.id }, "failed to record skill usage")); + } } catch (err) { const message = redactCurrentUserText( err instanceof Error ? err.message : "Unknown adapter failure", diff --git a/server/src/services/skill-registry-fetcher.ts b/server/src/services/skill-registry-fetcher.ts index 00738640..5e01fc70 100644 --- a/server/src/services/skill-registry-fetcher.ts +++ b/server/src/services/skill-registry-fetcher.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { eq } from "drizzle-orm"; import { getSkillRegistryDb, type SkillRegistryDb } from "./skill-registry-db.js"; -import { skills, skillVersions, skillFiles } from "./skill-registry-schema.js"; +import { skills, skillVersions, skillFiles, communityRatings } from "./skill-registry-schema.js"; import { fetchText, fetchJson, @@ -150,6 +150,36 @@ async function upsertSkill( }); } +/** + * Upsert a stub community_ratings row for a skill. + * This ensures list() and getById() JOINs always find a row. + * Real rating values are populated in v1.3 when community APIs are available. + */ +async function upsertCommunityRatingsStub( + db: SkillRegistryDb, + skillId: string, + sourceId: string, +): Promise { + await db + .insert(communityRatings) + .values({ + id: `${skillId}@${sourceId}`, + skillId, + fetchedAt: Date.now(), + averageRating: null, + ratingCount: null, + source: sourceId, + }) + .onConflictDoUpdate({ + target: communityRatings.id, + set: { + fetchedAt: Date.now(), + averageRating: null, + ratingCount: null, + }, + }); +} + /** * Check whether a version with this SHA already exists in the DB. * Returns true if already present (skip download). @@ -269,6 +299,8 @@ async function fetchAnthropicMarketplace( skillMdUrl, }); + await upsertCommunityRatingsStub(db, skillId, source.id); + fetched++; } @@ -332,6 +364,8 @@ async function fetchGitHubTree( skillMdUrl, }); + await upsertCommunityRatingsStub(db, skillId, source.id); + fetched++; } diff --git a/server/src/services/skill-registry.ts b/server/src/services/skill-registry.ts index 6309f1aa..b419d60e 100644 --- a/server/src/services/skill-registry.ts +++ b/server/src/services/skill-registry.ts @@ -1,8 +1,8 @@ -import { eq, isNull, and, desc } from "drizzle-orm"; +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 } from "./skill-registry-schema.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"; @@ -12,7 +12,15 @@ import { resolveSkillCacheDir } from "../home-paths.js"; type SkillRow = typeof skills.$inferSelect; type VersionRow = typeof skillVersions.$inferSelect; -type SkillListItem = SkillRow; + +/** 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 } @@ -31,18 +39,67 @@ export function skillRegistryService() { return { async list(opts?: { includeRemoved?: boolean }): Promise { 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`SUM(${agentSkills.taskCount})`, + avgCostUsd: sql`AVG(${agentSkills.avgCostUsd})`, + lastUsedAt: sql`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 db.select().from(skills); + return query as Promise; } - return db.select().from(skills).where(isNull(skills.removedAt)); + return query.where(isNull(skills.removedAt)) as Promise; }, - async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise { + async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise { const db = await getSkillRegistryDb(); + const conditions: Parameters[0][] = [eq(skills.id, skillId)]; if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt)); - const rows = await db.select().from(skills).where(and(...conditions)); - return rows[0]; + + 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`SUM(${agentSkills.taskCount})`, + avgCostUsd: sql`AVG(${agentSkills.avgCostUsd})`, + lastUsedAt: sql`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 {