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:
parent
bebacf5406
commit
ed87cc721f
3 changed files with 238 additions and 0 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
203
server/src/routes/skill-registry-groups.ts
Normal file
203
server/src/routes/skill-registry-groups.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue