nexus/server/src/routes/skill-registry.ts
Mikkel Georgsen 2bb998a7b2 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 07:46:09 +02:00

102 lines
4 KiB
TypeScript

import { Router } from "express";
import { skillRegistryService } from "../services/skill-registry.js";
import { skillRatingService } from "../services/skill-registry-ratings.js";
import { assertBoard } from "./authz.js";
/**
* REST routes for the skill registry.
*
* Note: does NOT take a db param — the skill registry manages its own libSQL database.
* All route handlers assert `board` access before delegating to skillRegistryService.
*/
export function skillRegistryRoutes(): Router {
const router = Router();
const svc = skillRegistryService();
// List all skills (soft-deleted excluded by default)
router.get("/skill-registry/skills", async (req, res) => {
assertBoard(req);
const includeRemoved = req.query.includeRemoved === "true";
const list = await svc.list({ includeRemoved });
res.json(list);
});
// Get versions for a skill — must be registered before the single-skill route
// to avoid /:id matching "versions" as the id segment
router.get("/skill-registry/skills/:sourceId/:slug/versions", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const versions = await svc.getVersions(skillId);
res.json(versions);
});
// Install skill to agent directory
router.post("/skill-registry/skills/:sourceId/:slug/install", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { agentSkillsDir } = req.body as { agentSkillsDir: string };
if (!agentSkillsDir) return res.status(400).json({ error: "agentSkillsDir required" });
const result = await svc.install(skillId, agentSkillsDir);
res.json(result);
});
// Rollback to a specific version
router.post("/skill-registry/skills/:sourceId/:slug/rollback", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { versionId, agentSkillsDir } = req.body as { versionId: string; agentSkillsDir: string };
if (!versionId || !agentSkillsDir) {
return res.status(400).json({ error: "versionId and agentSkillsDir required" });
}
await svc.rollback(skillId, versionId, agentSkillsDir);
res.json({ ok: true });
});
// Soft-delete a skill
router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
await svc.uninstall(skillId);
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);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const skill = await svc.getById(skillId);
if (!skill) return res.status(404).json({ error: "Skill not found" });
res.json(skill);
});
// Trigger fetch from all configured sources
router.post("/skill-registry/fetch", async (req, res) => {
assertBoard(req);
const result = await svc.fetchAll();
res.json(result);
});
return router;
}