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
b455942e79
commit
155b973e03
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 { companyRoutes } from "./routes/companies.js";
|
||||||
import { companySkillRoutes } from "./routes/company-skills.js";
|
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||||
import { skillRegistryRoutes } from "./routes/skill-registry.js";
|
import { skillRegistryRoutes } from "./routes/skill-registry.js";
|
||||||
|
import { skillGroupRoutes } from "./routes/skill-registry-groups.js";
|
||||||
import { agentRoutes } from "./routes/agents.js";
|
import { agentRoutes } from "./routes/agents.js";
|
||||||
import { projectRoutes } from "./routes/projects.js";
|
import { projectRoutes } from "./routes/projects.js";
|
||||||
import { issueRoutes } from "./routes/issues.js";
|
import { issueRoutes } from "./routes/issues.js";
|
||||||
|
|
@ -152,6 +153,7 @@ export async function createApp(
|
||||||
api.use("/companies", companyRoutes(db, opts.storageService));
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||||
api.use(companySkillRoutes(db));
|
api.use(companySkillRoutes(db));
|
||||||
api.use(skillRegistryRoutes());
|
api.use(skillRegistryRoutes());
|
||||||
|
api.use(skillGroupRoutes());
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
api.use(projectRoutes(db));
|
api.use(projectRoutes(db));
|
||||||
|
|
|
||||||
|
|
@ -624,6 +624,39 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// [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) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
const routines = routineService(db as any);
|
const routines = routineService(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