mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Implement skills store API foundation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
69a368ed51
commit
db599edadf
23 files changed
+2145
-53
No files matched your search
@@ -12,6 +12,19 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
categoryCounts: vi.fn(),
|
||||
detail: vi.fn(),
|
||||
listVersions: vi.fn(),
|
||||
getVersion: vi.fn(),
|
||||
createVersion: vi.fn(),
|
||||
starSkill: vi.fn(),
|
||||
unstarSkill: vi.fn(),
|
||||
forkSkill: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
importFromSource: vi.fn(),
|
||||
installFromCatalog: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
@@ -102,6 +115,101 @@ describe("company skill mutation permissions", () => {
|
||||
imported: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockCompanySkillService.list.mockResolvedValue([]);
|
||||
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
|
||||
mockCompanySkillService.detail.mockResolvedValue(null);
|
||||
mockCompanySkillService.listVersions.mockResolvedValue([]);
|
||||
mockCompanySkillService.getVersion.mockResolvedValue(null);
|
||||
mockCompanySkillService.createVersion.mockResolvedValue({
|
||||
id: "version-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 1,
|
||||
label: "v1",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Skill" }],
|
||||
authorAgentId: null,
|
||||
authorUserId: "board",
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
});
|
||||
mockCompanySkillService.starSkill.mockResolvedValue({
|
||||
skillId: "skill-1",
|
||||
starred: true,
|
||||
starCount: 1,
|
||||
});
|
||||
mockCompanySkillService.unstarSkill.mockResolvedValue({
|
||||
skillId: "skill-1",
|
||||
starred: false,
|
||||
starCount: 0,
|
||||
});
|
||||
mockCompanySkillService.forkSkill.mockResolvedValue({
|
||||
id: "skill-fork",
|
||||
companyId: "company-1",
|
||||
key: "company/company-1/review-fork",
|
||||
slug: "review-fork",
|
||||
name: "Review Fork",
|
||||
description: null,
|
||||
markdown: "# Review",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: "/tmp/review-fork",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
tagline: null,
|
||||
authorName: null,
|
||||
homepageUrl: null,
|
||||
categories: [],
|
||||
sharingScope: "company",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: "skill-1",
|
||||
forkedFromCompanyId: "company-1",
|
||||
starCount: 0,
|
||||
installCount: 1,
|
||||
forkCount: 0,
|
||||
currentVersionId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
});
|
||||
mockCompanySkillService.listComments.mockResolvedValue([]);
|
||||
mockCompanySkillService.createComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
parentCommentId: null,
|
||||
authorAgentId: null,
|
||||
authorUserId: "board",
|
||||
body: "Looks good",
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
});
|
||||
mockCompanySkillService.updateComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
parentCommentId: null,
|
||||
authorAgentId: null,
|
||||
authorUserId: "board",
|
||||
body: "Updated",
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
});
|
||||
mockCompanySkillService.deleteComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
parentCommentId: null,
|
||||
authorAgentId: null,
|
||||
authorUserId: "board",
|
||||
body: "Updated",
|
||||
deletedAt: new Date("2026-05-26T00:01:00.000Z"),
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
|
||||
});
|
||||
mockCompanySkillService.installFromCatalog.mockResolvedValue({
|
||||
action: "created",
|
||||
skill: {
|
||||
@@ -484,6 +592,77 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes store list filters and category count requests to the service", async () => {
|
||||
const app = await createApp({ type: "board", source: "local_implicit" });
|
||||
|
||||
await request(app)
|
||||
.get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review")
|
||||
.expect(200);
|
||||
expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", {
|
||||
q: "review",
|
||||
sort: "stars",
|
||||
categories: ["git", "memory"],
|
||||
scope: "company",
|
||||
});
|
||||
|
||||
await request(app).get("/api/companies/company-1/skills/categories").expect(200);
|
||||
expect(mockCompanySkillService.categoryCounts).toHaveBeenCalledWith("company-1");
|
||||
});
|
||||
|
||||
it("creates skill versions and logs the mutation", async () => {
|
||||
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
|
||||
|
||||
await request(app)
|
||||
.post("/api/companies/company-1/skills/skill-1/versions")
|
||||
.send({ label: "v1" })
|
||||
.expect(201);
|
||||
|
||||
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_version_created",
|
||||
entityType: "company_skill_version",
|
||||
entityId: "version-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("stars, forks, and comments on skills through company-scoped endpoints", async () => {
|
||||
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
|
||||
|
||||
await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200);
|
||||
expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await request(app).post("/api/companies/company-1/skills/skill-1/fork").send({ slug: "review-fork" }).expect(201);
|
||||
expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith("company-1", "skill-1", { slug: "review-fork" }, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
await request(app).post("/api/companies/company-1/skills/skill-1/comments").send({ body: "Looks good" }).expect(201);
|
||||
expect(mockCompanySkillService.createComment).toHaveBeenCalledWith("company-1", "skill-1", { body: "Looks good" }, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_starred",
|
||||
entityId: "skill-1",
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_forked",
|
||||
entityId: "skill-fork",
|
||||
}));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
action: "company.skill_comment_created",
|
||||
entityId: "comment-1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows agents with canCreateAgents to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
|
||||
@@ -143,6 +143,169 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("filters store list results by category and creates version snapshots", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-skill-"));
|
||||
cleanupDirs.add(skillDir);
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Versioned Skill\ncategories:\n - Memory\n---\n\n# Versioned Skill\n", "utf8");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key: `company/${companyId}/versioned-skill`,
|
||||
slug: "versioned-skill",
|
||||
name: "Versioned Skill",
|
||||
description: "Tracks revisions.",
|
||||
markdown: "# Versioned Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
categories: ["memory"],
|
||||
tagline: "Tracks revisions",
|
||||
});
|
||||
|
||||
const filtered = await svc.list(companyId, { categories: ["memory"], sort: "recent" });
|
||||
expect(filtered.some((skill) => skill.id === skillId)).toBe(true);
|
||||
expect(filtered.find((skill) => skill.id === skillId)).toMatchObject({
|
||||
categories: ["memory"],
|
||||
tagline: "Tracks revisions",
|
||||
});
|
||||
|
||||
const version = await svc.createVersion(companyId, skillId, { label: "v1" }, { type: "user", userId: "board" });
|
||||
expect(version).toMatchObject({
|
||||
companySkillId: skillId,
|
||||
revisionNumber: 1,
|
||||
label: "v1",
|
||||
authorUserId: "board",
|
||||
});
|
||||
expect(version.fileInventory).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content: expect.stringContaining("# Versioned Skill"),
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getVersion(companyId, skillId, version.id)).resolves.toMatchObject({ id: version.id });
|
||||
});
|
||||
|
||||
it("tracks stars and skill comments with actor ownership", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values({
|
||||
id: skillId,
|
||||
companyId,
|
||||
key: `company/${companyId}/discussion-skill`,
|
||||
slug: "discussion-skill",
|
||||
name: "Discussion Skill",
|
||||
description: null,
|
||||
markdown: "# Discussion Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
});
|
||||
|
||||
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
|
||||
starred: true,
|
||||
starCount: 1,
|
||||
});
|
||||
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
|
||||
starred: true,
|
||||
starCount: 1,
|
||||
});
|
||||
const comment = await svc.createComment(
|
||||
companyId,
|
||||
skillId,
|
||||
{ body: "Looks useful." },
|
||||
{ type: "user", userId: "board" },
|
||||
);
|
||||
expect(comment).toMatchObject({ body: "Looks useful.", authorUserId: "board" });
|
||||
await expect(svc.updateComment(
|
||||
companyId,
|
||||
skillId,
|
||||
comment.id,
|
||||
{ body: "Looks very useful." },
|
||||
{ type: "agent", agentId: randomUUID() },
|
||||
)).rejects.toMatchObject({ status: 422 });
|
||||
await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" }))
|
||||
.resolves.toMatchObject({ id: comment.id, deletedAt: expect.any(Date) });
|
||||
await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
|
||||
starred: false,
|
||||
starCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("validates version-aware desired skill selections", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const otherSkillId = randomUUID();
|
||||
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pinned-skill-"));
|
||||
cleanupDirs.add(skillDir);
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Pinned Skill\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companySkills).values([
|
||||
{
|
||||
id: skillId,
|
||||
companyId,
|
||||
key: `company/${companyId}/pinned-skill`,
|
||||
slug: "pinned-skill",
|
||||
name: "Pinned Skill",
|
||||
description: null,
|
||||
markdown: "# Pinned Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: skillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
},
|
||||
{
|
||||
id: otherSkillId,
|
||||
companyId,
|
||||
key: `company/${companyId}/other-skill`,
|
||||
slug: "other-skill",
|
||||
name: "Other Skill",
|
||||
description: null,
|
||||
markdown: "# Other Skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
},
|
||||
]);
|
||||
const version = await svc.createVersion(companyId, skillId, {}, { type: "user", userId: "board" });
|
||||
|
||||
await expect(svc.resolveRequestedSkillEntries(companyId, [
|
||||
{ key: "pinned-skill", versionId: version.id },
|
||||
])).resolves.toEqual([
|
||||
{ key: `company/${companyId}/pinned-skill`, versionId: version.id },
|
||||
]);
|
||||
await expect(svc.resolveRequestedSkillEntries(companyId, [
|
||||
{ key: "other-skill", versionId: version.id },
|
||||
])).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it("preserves missing local-path skills that active agents still desire", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
|
||||
+50
-17
@@ -16,6 +16,7 @@ import {
|
||||
normalizeIssueIdentifier,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentDesiredSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
upsertAgentInstructionsFileSchema,
|
||||
@@ -1282,18 +1283,39 @@ export function agentRoutes(
|
||||
|
||||
function buildUnsupportedSkillSnapshot(
|
||||
adapterType: string,
|
||||
desiredSkills: string[] = [],
|
||||
desiredSkillEntries: AgentDesiredSkillEntry[] = [],
|
||||
): AgentSkillSnapshot {
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
return {
|
||||
adapterType,
|
||||
supported: false,
|
||||
mode: "unsupported",
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
entries: [],
|
||||
warnings: ["This adapter does not implement skill sync yet."],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDesiredSkillSelections(
|
||||
requestedDesiredSkills: Array<string | AgentDesiredSkillEntry> | undefined,
|
||||
): AgentDesiredSkillEntry[] | undefined {
|
||||
if (!requestedDesiredSkills) return undefined;
|
||||
const out = new Map<string, AgentDesiredSkillEntry>();
|
||||
for (const value of requestedDesiredSkills) {
|
||||
const entry = typeof value === "string"
|
||||
? { key: value.trim(), versionId: null }
|
||||
: { key: value.key.trim(), versionId: value.versionId ?? null };
|
||||
if (!entry.key || out.has(entry.key)) continue;
|
||||
out.set(entry.key, entry);
|
||||
}
|
||||
return Array.from(out.values());
|
||||
}
|
||||
|
||||
function skillVersionSelectionMap(entries: Array<{ key: string; versionId: string | null }>) {
|
||||
return new Map(entries.map((entry) => [entry.key, entry.versionId] as const));
|
||||
}
|
||||
|
||||
// Legacy hardcoded set — used as fallback when adapter module does not
|
||||
// declare requiresMaterializedRuntimeSkills explicitly.
|
||||
const LEGACY_MATERIALIZED_SKILLS_SET = new Set([
|
||||
@@ -1319,9 +1341,11 @@ export function agentRoutes(
|
||||
materializeMissing?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||
materializeMissing: options.materializeMissing
|
||||
?? shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
|
||||
});
|
||||
return {
|
||||
...config,
|
||||
@@ -1333,31 +1357,39 @@ export function agentRoutes(
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
requestedDesiredSkills: string[] | undefined,
|
||||
requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined,
|
||||
) {
|
||||
if (!requestedDesiredSkills) {
|
||||
return {
|
||||
adapterConfig,
|
||||
desiredSkills: null as string[] | null,
|
||||
desiredSkillEntries: null as AgentDesiredSkillEntry[] | null,
|
||||
runtimeSkillEntries: null as Awaited<ReturnType<typeof companySkills.listRuntimeSkillEntries>> | null,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedRequestedSkills = await companySkills.resolveRequestedSkillKeys(
|
||||
const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries(
|
||||
companyId,
|
||||
requestedDesiredSkills,
|
||||
);
|
||||
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
const desiredSkills = Array.from(new Set([...requiredSkills, ...resolvedRequestedSkills]));
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...resolvedRequestedSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
|
||||
return {
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkills),
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, desiredSkillEntries),
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
runtimeSkillEntries,
|
||||
};
|
||||
}
|
||||
@@ -1574,9 +1606,14 @@ export function agentRoutes(
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
materializeMissing: false,
|
||||
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...preference.desiredSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1611,16 +1648,11 @@ export function agentRoutes(
|
||||
}
|
||||
await assertCanUpdateAgent(req, agent);
|
||||
|
||||
const requestedSkills = Array.from(
|
||||
new Set(
|
||||
(req.body.desiredSkills as string[])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const requestedSkills = normalizeDesiredSkillSelections(req.body.desiredSkills);
|
||||
const {
|
||||
adapterConfig: nextAdapterConfig,
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
runtimeSkillEntries,
|
||||
} = await resolveDesiredSkillAssignment(
|
||||
agent.companyId,
|
||||
@@ -1628,7 +1660,7 @@ export function agentRoutes(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
requestedSkills,
|
||||
);
|
||||
if (!desiredSkills || !runtimeSkillEntries) {
|
||||
if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) {
|
||||
throw unprocessable("Skill sync requires desiredSkills.");
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1669,7 +1701,7 @@ export function agentRoutes(
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeSkillConfig,
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkillEntries);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
@@ -1683,6 +1715,7 @@ export function agentRoutes(
|
||||
details: {
|
||||
adapterType: updated.adapterType,
|
||||
desiredSkills,
|
||||
desiredSkillEntries,
|
||||
mode: snapshot.mode,
|
||||
supported: snapshot.supported,
|
||||
entryCount: snapshot.entries.length,
|
||||
@@ -2093,7 +2126,7 @@ export function agentRoutes(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
|
||||
);
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
@@ -2279,7 +2312,7 @@ export function agentRoutes(
|
||||
companyId,
|
||||
createInput.adapterType,
|
||||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined),
|
||||
);
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
|
||||
@@ -2,13 +2,18 @@ import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
catalogSkillListQuerySchema,
|
||||
companySkillCommentCreateSchema,
|
||||
companySkillCommentUpdateSchema,
|
||||
companySkillCreateSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
companySkillForkSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillInstallCatalogSchema,
|
||||
companySkillInstallUpdateSchema,
|
||||
companySkillListQuerySchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillResetSchema,
|
||||
companySkillVersionCreateSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
@@ -63,6 +68,22 @@ export function companySkillRoutes(db: Db) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function queryStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
|
||||
return [];
|
||||
}
|
||||
|
||||
function skillActor(req: Request) {
|
||||
if (req.actor.type === "agent") {
|
||||
return { type: "agent" as const, agentId: req.actor.agentId ?? null };
|
||||
}
|
||||
if (req.actor.type === "board") {
|
||||
return { type: "user" as const, userId: req.actor.userId ?? "board" };
|
||||
}
|
||||
return { type: "system" as const };
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -118,15 +139,30 @@ export function companySkillRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/skills", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.list(companyId);
|
||||
const result = await svc.list(companyId, companySkillListQuerySchema.parse({
|
||||
q: firstQueryString(req.query.q),
|
||||
sort: firstQueryString(req.query.sort),
|
||||
categories: [
|
||||
...queryStringArray(req.query.category),
|
||||
...queryStringArray(req.query.categories),
|
||||
...queryStringArray(req.query["categories[]"]),
|
||||
],
|
||||
scope: firstQueryString(req.query.scope),
|
||||
}));
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/categories", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.categoryCounts(companyId));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.detail(companyId, skillId);
|
||||
const result = await svc.detail(companyId, skillId, skillActor(req));
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
return;
|
||||
@@ -134,6 +170,199 @@ export function companySkillRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/versions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listVersions(companyId, skillId));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/versions/:versionId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const versionId = req.params.versionId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.getVersion(companyId, skillId, versionId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill version not found" });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/versions",
|
||||
validate(companySkillVersionCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_version_created",
|
||||
entityType: "company_skill_version",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
skillId,
|
||||
revisionNumber: result.revisionNumber,
|
||||
label: result.label,
|
||||
},
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.starSkill(companyId, skillId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_starred",
|
||||
entityType: "company_skill",
|
||||
entityId: skillId,
|
||||
details: { starCount: result.starCount },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete("/companies/:companyId/skills/:skillId/star", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.unstarSkill(companyId, skillId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_unstarred",
|
||||
entityType: "company_skill",
|
||||
entityId: skillId,
|
||||
details: { starCount: result.starCount },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/fork",
|
||||
validate(companySkillForkSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_forked",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
sourceSkillId: skillId,
|
||||
slug: result.slug,
|
||||
name: result.name,
|
||||
},
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/comments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.listComments(companyId, skillId));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/skills/:skillId/comments",
|
||||
validate(companySkillCommentCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.createComment(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_created",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId, parentCommentId: result.parentCommentId },
|
||||
});
|
||||
res.status(201).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId/comments/:commentId",
|
||||
validate(companySkillCommentUpdateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.updateComment(companyId, skillId, commentId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_updated",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId },
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/skills/:skillId/comments/:commentId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
const commentId = req.params.commentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.deleteComment(companyId, skillId, commentId, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skill_comment_deleted",
|
||||
entityType: "company_skill_comment",
|
||||
entityId: result.id,
|
||||
details: { skillId },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user