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 { Router } from "express";
|
||||||
import { skillRegistryService } from "../services/skill-registry.js";
|
import { skillRegistryService } from "../services/skill-registry.js";
|
||||||
|
import { skillRatingService } from "../services/skill-registry-ratings.js";
|
||||||
import { assertBoard } from "./authz.js";
|
import { assertBoard } from "./authz.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,6 +60,28 @@ export function skillRegistryRoutes(): Router {
|
||||||
res.json({ ok: true });
|
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
|
// Get a single skill by id
|
||||||
router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
|
router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
|
|
|
||||||
|
|
@ -2717,6 +2717,11 @@ export function heartbeatService(db: Db) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await finalizeAgentStatus(agent.id, outcome);
|
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) {
|
} catch (err) {
|
||||||
const message = redactCurrentUserText(
|
const message = redactCurrentUserText(
|
||||||
err instanceof Error ? err.message : "Unknown adapter failure",
|
err instanceof Error ? err.message : "Unknown adapter failure",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getSkillRegistryDb, type SkillRegistryDb } from "./skill-registry-db.js";
|
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 {
|
import {
|
||||||
fetchText,
|
fetchText,
|
||||||
fetchJson,
|
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.
|
* Check whether a version with this SHA already exists in the DB.
|
||||||
* Returns true if already present (skip download).
|
* Returns true if already present (skip download).
|
||||||
|
|
@ -269,6 +299,8 @@ async function fetchAnthropicMarketplace(
|
||||||
skillMdUrl,
|
skillMdUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await upsertCommunityRatingsStub(db, skillId, source.id);
|
||||||
|
|
||||||
fetched++;
|
fetched++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,6 +364,8 @@ async function fetchGitHubTree(
|
||||||
skillMdUrl,
|
skillMdUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await upsertCommunityRatingsStub(db, skillId, source.id);
|
||||||
|
|
||||||
fetched++;
|
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 { cp, mkdir, rm } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getSkillRegistryDb } from "./skill-registry-db.js";
|
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 { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
|
||||||
import { resolveSkillCacheDir } from "../home-paths.js";
|
import { resolveSkillCacheDir } from "../home-paths.js";
|
||||||
|
|
||||||
|
|
@ -12,7 +12,15 @@ import { resolveSkillCacheDir } from "../home-paths.js";
|
||||||
|
|
||||||
type SkillRow = typeof skills.$inferSelect;
|
type SkillRow = typeof skills.$inferSelect;
|
||||||
type VersionRow = typeof skillVersions.$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 InstallResult =
|
||||||
| { type: "installed"; skillId: string; versionId: string; targetDir: string }
|
| { type: "installed"; skillId: string; versionId: string; targetDir: string }
|
||||||
|
|
@ -31,18 +39,67 @@ export function skillRegistryService() {
|
||||||
return {
|
return {
|
||||||
async list(opts?: { includeRemoved?: boolean }): Promise<SkillListItem[]> {
|
async list(opts?: { includeRemoved?: boolean }): Promise<SkillListItem[]> {
|
||||||
const db = await getSkillRegistryDb();
|
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) {
|
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 db = await getSkillRegistryDb();
|
||||||
|
|
||||||
const conditions: Parameters<typeof and>[0][] = [eq(skills.id, skillId)];
|
const conditions: Parameters<typeof and>[0][] = [eq(skills.id, skillId)];
|
||||||
if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt));
|
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[]> {
|
async getVersions(skillId: string): Promise<VersionRow[]> {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue