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
This commit is contained in:
Mikkel Georgsen 2026-04-01 03:31:02 +02:00 committed by Nexus Dev
parent bebacf5406
commit ed87cc721f
3 changed files with 238 additions and 0 deletions

View file

@ -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";
@ -143,6 +144,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));

View file

@ -611,6 +611,39 @@ export async function startServer(): Promise<StartedServer> {
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<string, string> = {
"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);

View file

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