Add company skill assignment to agent create and hire flows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-18 13:18:48 -05:00
parent 099c37c4b4
commit 480174367d
12 changed files with 699 additions and 35 deletions

View file

@ -394,6 +394,39 @@ export function agentRoutes(db: Db) {
};
}
async function resolveDesiredSkillAssignment(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
requestedDesiredSkills: string[] | undefined,
) {
if (!requestedDesiredSkills) {
return {
adapterConfig,
desiredSkills: null as string[] | null,
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
};
}
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
companyId,
requestedDesiredSkills,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
});
const requiredSkills = runtimeSkillEntries
.filter((entry) => entry.required)
.map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
return {
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
desiredSkills,
runtimeSkillEntries,
};
}
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
if (!agent) return null;
return {
@ -578,15 +611,19 @@ export function agentRoutes(db: Db) {
.filter(Boolean),
),
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(agent.adapterType),
});
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
const nextAdapterConfig = writePaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>,
const {
adapterConfig: nextAdapterConfig,
desiredSkills,
runtimeSkillEntries,
} = await resolveDesiredSkillAssignment(
agent.companyId,
agent.adapterType,
agent.adapterConfig as Record<string, unknown>,
requestedSkills,
);
if (!desiredSkills || !runtimeSkillEntries) {
throw unprocessable("Skill sync requires desiredSkills.");
}
const actor = getActorInfo(req);
const updated = await svc.update(agent.id, {
adapterConfig: nextAdapterConfig,
@ -955,14 +992,25 @@ export function agentRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanCreateAgentsForCompany(req, companyId);
const sourceIssueIds = parseSourceIssueIds(req.body);
const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body;
const {
desiredSkills: requestedDesiredSkills,
sourceIssueId: _sourceIssueId,
sourceIssueIds: _sourceIssueIds,
...hireInput
} = req.body;
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
);
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
companyId,
hireInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
requestedAdapterConfig,
desiredSkillAssignment.adapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
@ -1030,6 +1078,7 @@ export function agentRoutes(db: Db) {
typeof normalizedHireInput.budgetMonthlyCents === "number"
? normalizedHireInput.budgetMonthlyCents
: agent.budgetMonthlyCents,
desiredSkills: desiredSkillAssignment.desiredSkills,
metadata: requestedMetadata,
agentId: agent.id,
requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null,
@ -1037,6 +1086,7 @@ export function agentRoutes(db: Db) {
adapterType: requestedAdapterType,
adapterConfig: requestedAdapterConfig,
runtimeConfig: requestedRuntimeConfig,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
},
decisionNote: null,
@ -1068,6 +1118,7 @@ export function agentRoutes(db: Db) {
requiresApproval,
approvalId: approval?.id ?? null,
issueIds: sourceIssueIds,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
});
@ -1096,23 +1147,33 @@ export function agentRoutes(db: Db) {
assertBoard(req);
}
const {
desiredSkills: requestedDesiredSkills,
...createInput
} = req.body;
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
req.body.adapterType,
((req.body.adapterConfig ?? {}) as Record<string, unknown>),
createInput.adapterType,
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
);
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
companyId,
createInput.adapterType,
requestedAdapterConfig,
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
requestedAdapterConfig,
desiredSkillAssignment.adapterConfig,
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
companyId,
req.body.adapterType,
createInput.adapterType,
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
...req.body,
...createInput,
adapterConfig: normalizedAdapterConfig,
status: "idle",
spentMonthlyCents: 0,
@ -1129,7 +1190,11 @@ export function agentRoutes(db: Db) {
action: "agent.created",
entityType: "agent",
entityId: agent.id,
details: { name: agent.name, role: agent.role },
details: {
name: agent.name,
role: agent.role,
desiredSkills: desiredSkillAssignment.desiredSkills,
},
});
if (agent.budgetMonthlyCents > 0) {

View file

@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
companySkillCreateSchema,
@ -7,13 +7,50 @@ import {
companySkillProjectScanRequestSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { companySkillService, logActivity } from "../services/index.js";
import { accessService, agentService, companySkillService, logActivity } from "../services/index.js";
import { forbidden } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function companySkillRoutes(db: Db) {
const router = Router();
const agents = agentService(db);
const access = accessService(db);
const svc = companySkillService(db);
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "agents:create");
if (!allowed) {
throw forbidden("Missing permission: agents:create");
}
return;
}
if (!req.actor.agentId) {
throw forbidden("Agent authentication required");
}
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create");
if (allowedByGrant || canCreateAgents(actorAgent)) {
return;
}
throw forbidden("Missing permission: can create agents");
}
router.get("/companies/:companyId/skills", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@ -63,7 +100,7 @@ export function companySkillRoutes(db: Db) {
validate(companySkillCreateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.createLocalSkill(companyId, req.body);
const actor = getActorInfo(req);
@ -92,7 +129,7 @@ export function companySkillRoutes(db: Db) {
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.updateFile(
companyId,
skillId,
@ -125,7 +162,7 @@ export function companySkillRoutes(db: Db) {
validate(companySkillImportSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const result = await svc.importFromSource(companyId, source);
@ -156,7 +193,7 @@ export function companySkillRoutes(db: Db) {
validate(companySkillProjectScanRequestSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.scanProjectWorkspaces(companyId, req.body);
const actor = getActorInfo(req);
@ -187,7 +224,7 @@ export function companySkillRoutes(db: Db) {
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
assertCompanyAccess(req, companyId);
await assertCanMutateCompanySkills(req, companyId);
const result = await svc.installUpdate(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });