mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +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();
|
||||
|
||||
Reference in new issue
Block a user