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:
parent
1a1c3ce399
commit
b52f5a8adf
4 changed files with 128 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue