From 155b973e03332f381e88f816eef0a85d256d4231 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 03:31:02 +0200 Subject: [PATCH] feat(11-03): add skill group routes, mount in app.ts, startup reconciliation - Create server/src/routes/skill-registry-groups.ts with skillGroupRoutes() factory - All 14 REST routes for group CRUD, members, export/import, and agent assignments - Import route registered before :groupId param to avoid route collision - assertBoard on every handler, error classification (400/404/409/500) - Mount skillGroupRoutes() in app.ts after skillRegistryRoutes() - Add pendingSkillGroups fire-and-forget reconciliation in index.ts startup --- server/src/app.ts | 2 + server/src/index.ts | 33 ++++ server/src/routes/skill-registry-groups.ts | 203 +++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 server/src/routes/skill-registry-groups.ts diff --git a/server/src/app.ts b/server/src/app.ts index 2606bf3b..e55edcf0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -13,6 +13,7 @@ import { healthRoutes } from "./routes/health.js"; import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; import { skillRegistryRoutes } from "./routes/skill-registry.js"; +import { skillGroupRoutes } from "./routes/skill-registry-groups.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; @@ -152,6 +153,7 @@ export async function createApp( api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); api.use(skillRegistryRoutes()); + api.use(skillGroupRoutes()); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); diff --git a/server/src/index.ts b/server/src/index.ts index 105c2830..5703a4cc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -623,6 +623,39 @@ export async function startServer(): Promise { logger.error({ err }, "skill registry init failed"); } })(); + + // [nexus] Reconcile pendingSkillGroups metadata on agents (fire-and-forget) + void (async () => { + try { + const { join } = await import("node:path"); + const { skillGroupService } = await import("./services/skill-registry-groups.js"); + const { resolveDefaultAgentWorkspaceDir } = await import("./home-paths.js"); + const svc = skillGroupService(); + const GROUP_NAME_MAP: Record = { + "Creative": "builtin/creative", + "PM Essentials": "builtin/pm-essentials", + "Engineer Core": "builtin/engineer-core", + "Frontend": "builtin/frontend", + "Backend": "builtin/backend", + }; + const allAgents = await (db as any).select().from(agents); + for (const agent of allAgents) { + const pending = (agent.metadata as any)?.pendingSkillGroups; + if (!Array.isArray(pending) || pending.length === 0) continue; + const agentSkillsDir = join(resolveDefaultAgentWorkspaceDir(agent), ".claude", "skills"); + for (const groupName of pending) { + const groupId = GROUP_NAME_MAP[groupName as string]; + if (!groupId) continue; + const existing = await svc.listAgentGroups(agent.id); + if (existing.some((g) => g.id === groupId)) continue; + await svc.assignGroup(groupId, agent.id, agentSkillsDir); + logger.info({ agentId: agent.id, groupId }, "reconciled pendingSkillGroups assignment"); + } + } + } catch (err) { + logger.warn({ err }, "Failed to reconcile pendingSkillGroups"); + } + })(); if (config.heartbeatSchedulerEnabled) { const heartbeat = heartbeatService(db as any); diff --git a/server/src/routes/skill-registry-groups.ts b/server/src/routes/skill-registry-groups.ts new file mode 100644 index 00000000..1e4d6f6f --- /dev/null +++ b/server/src/routes/skill-registry-groups.ts @@ -0,0 +1,203 @@ +import { Router } from "express"; +import { skillGroupService } from "../services/skill-registry-groups.js"; +import { assertBoard } from "./authz.js"; + +/** + * REST routes for skill groups. + * + * Note: does NOT take a db param — skill groups use the libSQL registry.db. + * All route handlers assert `board` access before delegating to skillGroupService. + */ +export function skillGroupRoutes(): Router { + const router = Router(); + const svc = skillGroupService(); + + function handleError(res: any, err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if ( + msg.includes("Cannot delete built-in") || + msg.includes("not found") || + msg.includes("cycle") || + msg.includes("required") + ) { + return res.status(400).json({ error: msg }); + } + if (msg.includes("already exists")) { + return res.status(409).json({ error: msg }); + } + return res.status(500).json({ error: msg }); + } + + // --- Group CRUD --- + + router.get("/skill-registry/groups", async (req, res) => { + assertBoard(req); + try { + const groups = await svc.listGroups(); + res.json(groups); + } catch (err) { + handleError(res, err); + } + }); + + // Import route must come BEFORE /:groupId to avoid "import" being captured as a groupId param + router.post("/skill-registry/groups/import", async (req, res) => { + assertBoard(req); + try { + const result = await svc.importGroup(req.body); + res.status(201).json(result); + } catch (err) { + handleError(res, err); + } + }); + + router.post("/skill-registry/groups", async (req, res) => { + assertBoard(req); + try { + const { name, description } = req.body as { name?: string; description?: string }; + if (!name) { + return res.status(400).json({ error: "name required" }); + } + const group = await svc.createGroup({ name, description }); + res.status(201).json(group); + } catch (err) { + handleError(res, err); + } + }); + + router.get("/skill-registry/groups/:groupId", async (req, res) => { + assertBoard(req); + try { + const group = await svc.getGroup(req.params.groupId); + if (!group) { + return res.status(404).json({ error: "Group not found" }); + } + res.json(group); + } catch (err) { + handleError(res, err); + } + }); + + router.patch("/skill-registry/groups/:groupId", async (req, res) => { + assertBoard(req); + try { + const { name, description } = req.body as { name?: string; description?: string }; + const group = await svc.updateGroup(req.params.groupId, { name, description }); + res.json(group); + } catch (err) { + handleError(res, err); + } + }); + + router.delete("/skill-registry/groups/:groupId", async (req, res) => { + assertBoard(req); + try { + await svc.deleteGroup(req.params.groupId); + res.status(204).end(); + } catch (err) { + handleError(res, err); + } + }); + + // --- Members --- + + router.get("/skill-registry/groups/:groupId/members", async (req, res) => { + assertBoard(req); + try { + const members = await svc.listMembers(req.params.groupId); + res.json(members); + } catch (err) { + handleError(res, err); + } + }); + + router.post("/skill-registry/groups/:groupId/members", async (req, res) => { + assertBoard(req); + try { + const { skillId } = req.body as { skillId?: string }; + if (!skillId) { + return res.status(400).json({ error: "skillId required" }); + } + await svc.addMember(req.params.groupId, skillId); + res.status(201).json({ ok: true }); + } catch (err) { + handleError(res, err); + } + }); + + router.delete("/skill-registry/groups/:groupId/members/:skillId(*)", async (req, res) => { + assertBoard(req); + try { + const skillId = req.params.skillId; + await svc.removeMember(req.params.groupId, skillId); + res.status(204).end(); + } catch (err) { + handleError(res, err); + } + }); + + // --- Export --- + + router.get("/skill-registry/groups/:groupId/export", async (req, res) => { + assertBoard(req); + try { + const data = await svc.exportGroup(req.params.groupId); + res.setHeader("Content-Disposition", `attachment; filename="${data.group.name}.json"`); + res.json(data); + } catch (err) { + handleError(res, err); + } + }); + + // --- Agent group assignments --- + + router.get("/skill-registry/agents/:agentId/groups", async (req, res) => { + assertBoard(req); + try { + const groups = await svc.listAgentGroups(req.params.agentId); + res.json(groups); + } catch (err) { + handleError(res, err); + } + }); + + router.post("/skill-registry/agents/:agentId/groups", async (req, res) => { + assertBoard(req); + try { + const { groupId, agentSkillsDir } = req.body as { groupId?: string; agentSkillsDir?: string }; + if (!groupId || !agentSkillsDir) { + return res.status(400).json({ error: "groupId and agentSkillsDir required" }); + } + const result = await svc.assignGroup(groupId, req.params.agentId, agentSkillsDir); + res.status(201).json(result); + } catch (err) { + handleError(res, err); + } + }); + + router.delete("/skill-registry/agents/:agentId/groups/:groupId(*)", async (req, res) => { + assertBoard(req); + try { + const { agentSkillsDir } = req.body as { agentSkillsDir?: string }; + if (!agentSkillsDir) { + return res.status(400).json({ error: "agentSkillsDir required" }); + } + await svc.removeGroup(req.params.groupId, req.params.agentId, agentSkillsDir); + res.status(204).end(); + } catch (err) { + handleError(res, err); + } + }); + + router.get("/skill-registry/agents/:agentId/skills", async (req, res) => { + assertBoard(req); + try { + const skills = await svc.listAgentSkills(req.params.agentId); + res.json(skills); + } catch (err) { + handleError(res, err); + } + }); + + return router; +}