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
This commit is contained in:
Mikkel Georgsen 2026-04-01 04:08:18 +02:00
parent 1a1c3ce399
commit b52f5a8adf
4 changed files with 128 additions and 9 deletions

View file

@ -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);

View file

@ -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",

View file

@ -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<void> {
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++;
}

View file

@ -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<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 db.select().from(skills);
return query as Promise<SkillListItem[]>;
}
return db.select().from(skills).where(isNull(skills.removedAt));
return query.where(isNull(skills.removedAt)) as Promise<SkillListItem[]>;
},
async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise<SkillRow | undefined> {
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().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<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[]> {