feat(skills): add company skill fork prechecks (#9235)
Adds company skill fork precheck metadata, fork result/reassignment contracts, selected-agent reassignment during fork creation, and targeted server/shared test coverage.
This commit is contained in:
parent
f616b6746c
commit
4a7a732476
|
|
@ -455,6 +455,11 @@ export type {
|
|||
CompanySkillCommentCreateRequest,
|
||||
CompanySkillCommentUpdateRequest,
|
||||
CompanySkillForkRequest,
|
||||
CompanySkillOriginalSummary,
|
||||
CompanySkillForkSummary,
|
||||
CompanySkillForkReassignment,
|
||||
CompanySkillForkResult,
|
||||
CompanySkillForkPrecheckResult,
|
||||
CompanySkillUpdateRequest,
|
||||
CompanySkillUpdateStatus,
|
||||
CompanySkillAuditSeverity,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export interface CompanySkillUsageAgent {
|
|||
export interface CompanySkillDetail extends CompanySkill {
|
||||
attachedAgentCount: number;
|
||||
usedByAgents: CompanySkillUsageAgent[];
|
||||
existingForks: CompanySkillForkSummary[];
|
||||
editable: boolean;
|
||||
editableReason: string | null;
|
||||
sourceLabel: string | null;
|
||||
|
|
@ -180,6 +181,47 @@ export interface CompanySkillForkRequest {
|
|||
name?: string | null;
|
||||
slug?: string | null;
|
||||
sharingScope?: CompanySkillSharingScope;
|
||||
reassignAgentIds?: string[];
|
||||
}
|
||||
|
||||
export interface CompanySkillOriginalSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
sourceType: CompanySkillSourceType;
|
||||
sourceLocator: string | null;
|
||||
sourceRef: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySkillForkSummary extends CompanySkillOriginalSummary {
|
||||
key: string;
|
||||
forkedFromSkillId: string | null;
|
||||
forkedFromCompanyId: string | null;
|
||||
currentVersionId: string | null;
|
||||
createdByCurrentActor: boolean;
|
||||
diverged: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CompanySkillForkReassignment {
|
||||
agentId: string;
|
||||
previousSkillKey: string;
|
||||
nextSkillKey: string;
|
||||
}
|
||||
|
||||
export interface CompanySkillForkResult {
|
||||
skill: CompanySkill;
|
||||
original: CompanySkillOriginalSummary;
|
||||
reassignments: CompanySkillForkReassignment[];
|
||||
}
|
||||
|
||||
export interface CompanySkillForkPrecheckResult {
|
||||
skillId: string;
|
||||
original: CompanySkillOriginalSummary;
|
||||
agentUsageCount: number;
|
||||
usedByAgents: CompanySkillUsageAgent[];
|
||||
existingForks: CompanySkillForkSummary[];
|
||||
}
|
||||
|
||||
export interface CompanySkillUpdateRequest {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ export type {
|
|||
CompanySkillCommentCreateRequest,
|
||||
CompanySkillCommentUpdateRequest,
|
||||
CompanySkillForkRequest,
|
||||
CompanySkillOriginalSummary,
|
||||
CompanySkillForkSummary,
|
||||
CompanySkillForkReassignment,
|
||||
CompanySkillForkResult,
|
||||
CompanySkillForkPrecheckResult,
|
||||
CompanySkillUpdateRequest,
|
||||
CompanySkillUpdateStatus,
|
||||
CompanySkillAuditSeverity,
|
||||
|
|
|
|||
|
|
@ -73,6 +73,26 @@ export const companySkillUsageAgentSchema = z.object({
|
|||
versionId: z.string().uuid().nullable(),
|
||||
});
|
||||
|
||||
export const companySkillOriginalSummarySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
sourceType: companySkillSourceTypeSchema,
|
||||
sourceLocator: z.string().nullable(),
|
||||
sourceRef: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const companySkillForkSummarySchema = companySkillOriginalSummarySchema.extend({
|
||||
key: z.string().min(1),
|
||||
forkedFromSkillId: z.string().uuid().nullable(),
|
||||
forkedFromCompanyId: z.string().uuid().nullable(),
|
||||
currentVersionId: z.string().uuid().nullable(),
|
||||
createdByCurrentActor: z.boolean(),
|
||||
diverged: z.boolean(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const companySkillListQuerySchema = z.object({
|
||||
q: z.string().min(1).optional(),
|
||||
sort: companySkillListSortSchema.optional(),
|
||||
|
|
@ -100,6 +120,7 @@ export const companySkillVersionSchema = z.object({
|
|||
export const companySkillDetailSchema = companySkillSchema.extend({
|
||||
attachedAgentCount: z.number().int().nonnegative(),
|
||||
usedByAgents: z.array(companySkillUsageAgentSchema).default([]),
|
||||
existingForks: z.array(companySkillForkSummarySchema).default([]),
|
||||
editable: z.boolean(),
|
||||
editableReason: z.string().nullable(),
|
||||
sourceLabel: z.string().nullable(),
|
||||
|
|
@ -144,8 +165,29 @@ export const companySkillForkSchema = z.object({
|
|||
name: z.string().min(1).nullable().optional(),
|
||||
slug: z.string().min(1).nullable().optional(),
|
||||
sharingScope: companySkillSharingScopeSchema.optional(),
|
||||
reassignAgentIds: z.array(z.string().uuid()).optional(),
|
||||
}).default({});
|
||||
|
||||
export const companySkillForkReassignmentSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
previousSkillKey: z.string().min(1),
|
||||
nextSkillKey: z.string().min(1),
|
||||
});
|
||||
|
||||
export const companySkillForkResultSchema = z.object({
|
||||
skill: companySkillSchema,
|
||||
original: companySkillOriginalSummarySchema,
|
||||
reassignments: z.array(companySkillForkReassignmentSchema),
|
||||
});
|
||||
|
||||
export const companySkillForkPrecheckResultSchema = z.object({
|
||||
skillId: z.string().uuid(),
|
||||
original: companySkillOriginalSummarySchema,
|
||||
agentUsageCount: z.number().int().nonnegative(),
|
||||
usedByAgents: z.array(companySkillUsageAgentSchema),
|
||||
existingForks: z.array(companySkillForkSummarySchema),
|
||||
});
|
||||
|
||||
export const companySkillUpdateSchema = z.object({
|
||||
description: z.string().nullable().optional(),
|
||||
iconUrl: z.string().nullable().optional(),
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ export {
|
|||
companySkillCommentCreateSchema,
|
||||
companySkillCommentUpdateSchema,
|
||||
companySkillForkSchema,
|
||||
companySkillOriginalSummarySchema,
|
||||
companySkillForkSummarySchema,
|
||||
companySkillForkReassignmentSchema,
|
||||
companySkillForkResultSchema,
|
||||
companySkillForkPrecheckResultSchema,
|
||||
companySkillUpdateSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillAuditFindingSchema,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
|||
starSkill: vi.fn(),
|
||||
unstarSkill: vi.fn(),
|
||||
forkSkill: vi.fn(),
|
||||
forkPrecheck: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
|
|
@ -150,7 +151,7 @@ describe("company skill mutation permissions", () => {
|
|||
starred: false,
|
||||
starCount: 0,
|
||||
});
|
||||
mockCompanySkillService.forkSkill.mockResolvedValue({
|
||||
const forkedSkill = {
|
||||
id: "skill-fork",
|
||||
companyId: "company-1",
|
||||
key: "company/company-1/review-fork",
|
||||
|
|
@ -181,6 +182,32 @@ describe("company skill mutation permissions", () => {
|
|||
metadata: null,
|
||||
createdAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
|
||||
};
|
||||
mockCompanySkillService.forkSkill.mockResolvedValue({
|
||||
skill: forkedSkill,
|
||||
original: {
|
||||
id: "skill-1",
|
||||
name: "Review",
|
||||
slug: "review",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/acme/review",
|
||||
sourceRef: "abc123",
|
||||
},
|
||||
reassignments: [],
|
||||
});
|
||||
mockCompanySkillService.forkPrecheck.mockResolvedValue({
|
||||
skillId: "skill-1",
|
||||
original: {
|
||||
id: "skill-1",
|
||||
name: "Review",
|
||||
slug: "review",
|
||||
sourceType: "github",
|
||||
sourceLocator: "https://github.com/acme/review",
|
||||
sourceRef: "abc123",
|
||||
},
|
||||
agentUsageCount: 0,
|
||||
usedByAgents: [],
|
||||
existingForks: [],
|
||||
});
|
||||
mockCompanySkillService.listComments.mockResolvedValue([]);
|
||||
mockCompanySkillService.createComment.mockResolvedValue({
|
||||
|
|
@ -834,8 +861,27 @@ describe("company skill mutation permissions", () => {
|
|||
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" }, {
|
||||
const forkRes = await request(app)
|
||||
.post("/api/companies/company-1/skills/skill-1/fork")
|
||||
.send({ slug: "review-fork", reassignAgentIds: ["11111111-1111-4111-8111-111111111111"] })
|
||||
.expect(201);
|
||||
expect(forkRes.body).toMatchObject({
|
||||
skill: { id: "skill-fork", slug: "review-fork" },
|
||||
original: { id: "skill-1", slug: "review" },
|
||||
reassignments: [],
|
||||
});
|
||||
expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"skill-1",
|
||||
{ slug: "review-fork", reassignAgentIds: ["11111111-1111-4111-8111-111111111111"] },
|
||||
{
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
},
|
||||
);
|
||||
|
||||
await request(app).get("/api/companies/company-1/skills/skill-1/fork-precheck").expect(200);
|
||||
expect(mockCompanySkillService.forkPrecheck).toHaveBeenCalledWith("company-1", "skill-1", {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, companySkills, createDb } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -428,12 +429,24 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
authorUserId: "board",
|
||||
});
|
||||
|
||||
const dedicatedFork = await svc.forkSkill(
|
||||
const dedicatedForkResult = await svc.forkSkill(
|
||||
companyId,
|
||||
sourceSkillId,
|
||||
{ name: "Dedicated Fork", slug: "dedicated-fork", sharingScope: "private" },
|
||||
{ type: "user", userId: "board" },
|
||||
);
|
||||
const dedicatedFork = dedicatedForkResult.skill;
|
||||
expect(dedicatedForkResult).toMatchObject({
|
||||
original: {
|
||||
id: sourceSkillId,
|
||||
name: "Source Skill",
|
||||
slug: "source-skill",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: sourceSkillDir,
|
||||
sourceRef: null,
|
||||
},
|
||||
reassignments: [],
|
||||
});
|
||||
expect(dedicatedFork).toMatchObject({
|
||||
name: "Source Skill",
|
||||
slug: "dedicated-fork",
|
||||
|
|
@ -451,6 +464,167 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("prechecks existing forks and reassigns selected agents when forking", async () => {
|
||||
const companyId = randomUUID();
|
||||
const sourceSkillId = randomUUID();
|
||||
const sourceSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-reassign-source-"));
|
||||
cleanupDirs.add(sourceSkillDir);
|
||||
await fs.writeFile(path.join(sourceSkillDir, "SKILL.md"), "# Source 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: sourceSkillId,
|
||||
companyId,
|
||||
key: `company/${companyId}/source-skill`,
|
||||
slug: "source-skill",
|
||||
name: "Source Skill",
|
||||
description: null,
|
||||
markdown: "# Source Skill\n",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: sourceSkillDir,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: { sourceKind: "managed_local" },
|
||||
});
|
||||
const reassignAgentId = randomUUID();
|
||||
const keepAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: reassignAgentId,
|
||||
companyId,
|
||||
name: "Reassign Me",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [`company/${companyId}/source-skill`],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: keepAgentId,
|
||||
companyId,
|
||||
name: "Keep Me",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [`company/${companyId}/source-skill`],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const before = await svc.forkPrecheck(companyId, sourceSkillId, { type: "user", userId: "board" });
|
||||
expect(before).toMatchObject({
|
||||
skillId: sourceSkillId,
|
||||
original: { id: sourceSkillId, slug: "source-skill" },
|
||||
agentUsageCount: 2,
|
||||
existingForks: [],
|
||||
});
|
||||
|
||||
const forked = await svc.forkSkill(
|
||||
companyId,
|
||||
sourceSkillId,
|
||||
{ slug: "source-skill-fork", reassignAgentIds: [reassignAgentId] },
|
||||
{ type: "user", userId: "board" },
|
||||
);
|
||||
|
||||
expect(forked).toMatchObject({
|
||||
skill: {
|
||||
slug: "source-skill-fork",
|
||||
key: `company/${companyId}/source-skill-fork`,
|
||||
forkedFromSkillId: sourceSkillId,
|
||||
},
|
||||
original: { id: sourceSkillId, slug: "source-skill" },
|
||||
reassignments: [{
|
||||
agentId: reassignAgentId,
|
||||
previousSkillKey: `company/${companyId}/source-skill`,
|
||||
nextSkillKey: `company/${companyId}/source-skill-fork`,
|
||||
}],
|
||||
});
|
||||
const afterAgents = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
const reassignConfig = afterAgents.find((agent) => agent.id === reassignAgentId)?.adapterConfig as Record<string, any>;
|
||||
const keepConfig = afterAgents.find((agent) => agent.id === keepAgentId)?.adapterConfig as Record<string, any>;
|
||||
expect(reassignConfig.paperclipSkillSync.desiredSkills).toEqual([`company/${companyId}/source-skill-fork`]);
|
||||
expect(keepConfig.paperclipSkillSync.desiredSkills).toEqual([`company/${companyId}/source-skill`]);
|
||||
|
||||
const after = await svc.forkPrecheck(companyId, sourceSkillId, { type: "user", userId: "board" });
|
||||
expect(after?.existingForks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: forked.skill.id,
|
||||
key: forked.skill.key,
|
||||
createdByCurrentActor: true,
|
||||
diverged: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("forks external source types and deduplicates fork slugs", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const sourceTypes = ["github", "skills_sh", "url", "catalog"] as const;
|
||||
await db.insert(companySkills).values(sourceTypes.map((sourceType) => ({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
key: `company/${companyId}/${sourceType}-skill`,
|
||||
slug: `${sourceType}-skill`,
|
||||
name: `${sourceType} Skill`,
|
||||
description: null,
|
||||
markdown: `# ${sourceType} Skill\n`,
|
||||
sourceType,
|
||||
sourceLocator: sourceType === "url"
|
||||
? `https://example.com/${sourceType}.md`
|
||||
: sourceType === "catalog"
|
||||
? null
|
||||
: `https://github.com/acme/${sourceType}-skill`,
|
||||
sourceRef: sourceType === "github" || sourceType === "skills_sh" ? "main" : null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
metadata: sourceType === "github" || sourceType === "skills_sh"
|
||||
? { sourceKind: sourceType, owner: "acme", repo: `${sourceType}-skill`, ref: "main", repoSkillDir: "." }
|
||||
: { sourceKind: sourceType },
|
||||
})));
|
||||
|
||||
const remoteReads: string[] = [];
|
||||
vi.stubGlobal("fetch", async (url: string | URL) => {
|
||||
remoteReads.push(String(url));
|
||||
return new Response("# Remote Skill\n", { status: 200 });
|
||||
});
|
||||
try {
|
||||
for (const sourceType of sourceTypes) {
|
||||
const source = await svc.getByKey(companyId, `company/${companyId}/${sourceType}-skill`);
|
||||
expect(source).not.toBeNull();
|
||||
const first = await svc.forkSkill(companyId, source!.id, { slug: `${sourceType}-skill-fork` }, { type: "user", userId: "board" });
|
||||
const second = await svc.forkSkill(companyId, source!.id, { slug: `${sourceType}-skill-fork` }, { type: "user", userId: "board" });
|
||||
const normalizedForkSlug = `${sourceType.replace("_", "-")}-skill-fork`;
|
||||
expect(first.skill).toMatchObject({
|
||||
slug: normalizedForkSlug,
|
||||
sourceType: "local_path",
|
||||
forkedFromSkillId: source!.id,
|
||||
});
|
||||
expect(second.skill.slug).toBe(`${normalizedForkSlug}-2`);
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
expect(remoteReads).toEqual(expect.arrayContaining([
|
||||
"https://raw.githubusercontent.com/acme/github-skill/main/SKILL.md",
|
||||
"https://raw.githubusercontent.com/acme/skills_sh-skill/main/SKILL.md",
|
||||
]));
|
||||
});
|
||||
|
||||
it("validates version-aware desired skill selections", async () => {
|
||||
const companyId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -172,13 +172,35 @@ async function waitForRunToFinish(heartbeat: Heartbeat, runId: string, timeoutMs
|
|||
|
||||
async function waitForHeartbeatIdle(db: Db, timeoutMs = 5_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let idleSince: number | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
|
||||
if (!runs.some((run) => run.status === "queued" || run.status === "running")) return;
|
||||
if (!runs.some((run) => run.status === "queued" || run.status === "running")) {
|
||||
idleSince ??= Date.now();
|
||||
if (Date.now() - idleSince >= 250) return;
|
||||
} else {
|
||||
idleSince = null;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHeartbeatRunsForCleanup(db: Db) {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
try {
|
||||
await db.delete(heartbeatRuns);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function waitForContainmentSideEffects(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
|
|
@ -873,9 +895,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
|||
await db.delete(heartbeatRunEvents);
|
||||
// Heartbeat failure/finalization paths can emit run-linked events and
|
||||
// activity after the first cleanup pass observes all runs as non-active.
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await deleteHeartbeatRunsForCleanup(db);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(projectWorkspaces);
|
||||
|
|
|
|||
|
|
@ -179,6 +179,18 @@ export function companySkillRoutes(db: Db) {
|
|||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/skills/:skillId/fork-precheck", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const result = await svc.forkPrecheck(companyId, skillId, skillActor(req));
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
|
@ -284,11 +296,12 @@ export function companySkillRoutes(db: Db) {
|
|||
runId: actor.runId,
|
||||
action: "company.skill_forked",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
entityId: result.skill.id,
|
||||
details: {
|
||||
sourceSkillId: skillId,
|
||||
slug: result.slug,
|
||||
name: result.name,
|
||||
slug: result.skill.slug,
|
||||
name: result.skill.name,
|
||||
reassignedAgentIds: result.reassignments.map((entry: { agentId: string }) => entry.agentId),
|
||||
},
|
||||
});
|
||||
res.status(201).json(result);
|
||||
|
|
|
|||
|
|
@ -4862,6 +4862,7 @@ for (const route of [
|
|||
["get", "/api/companies/{companyId}/skills/{skillId}/versions/{versionId}", "Get a skill version"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/star", "Star a company skill"],
|
||||
["delete", "/api/companies/{companyId}/skills/{skillId}/star", "Unstar a company skill"],
|
||||
["get", "/api/companies/{companyId}/skills/{skillId}/fork-precheck", "Preview company skill fork impact"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/fork", "Fork a company skill"],
|
||||
["get", "/api/companies/{companyId}/skills/{skillId}/comments", "List skill comments"],
|
||||
["post", "/api/companies/{companyId}/skills/{skillId}/comments", "Create a skill comment"],
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, companySkillComments, companySkillStars, companySkillVersions, companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { agents as agentsTable, companies, companySkillComments, companySkillStars, companySkillVersions, companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { PaperclipDesiredSkillEntry, PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type {
|
||||
AgentDesiredSkillEntry,
|
||||
|
|
@ -23,12 +23,17 @@ import type {
|
|||
CompanySkillDetail,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillFileInventoryEntry,
|
||||
CompanySkillForkPrecheckResult,
|
||||
CompanySkillForkRequest,
|
||||
CompanySkillForkResult,
|
||||
CompanySkillForkReassignment,
|
||||
CompanySkillForkSummary,
|
||||
CompanySkillImportResult,
|
||||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
CompanySkillListQuery,
|
||||
CompanySkillListItem,
|
||||
CompanySkillOriginalSummary,
|
||||
CompanySkillProjectScanConflict,
|
||||
CompanySkillProjectScanRequest,
|
||||
CompanySkillProjectScanResult,
|
||||
|
|
@ -247,6 +252,8 @@ type SkillSourceMeta = {
|
|||
originVersion?: string;
|
||||
originSnapshotLocator?: string;
|
||||
installedHash?: string;
|
||||
forkedByAgentId?: string | null;
|
||||
forkedByUserId?: string | null;
|
||||
userModifiedAt?: string | null;
|
||||
updateHoldReason?: CompanySkillUpdateHoldReason | null;
|
||||
auditVerdict?: CompanySkillAuditVerdict;
|
||||
|
|
@ -284,6 +291,11 @@ type SkillActor = {
|
|||
userId?: string | null;
|
||||
};
|
||||
|
||||
type PlannedSkillReassignment = {
|
||||
agentId: string;
|
||||
reassignment: CompanySkillForkReassignment;
|
||||
};
|
||||
|
||||
type RuntimeSkillSourceResolution =
|
||||
| { status: "available"; source: string }
|
||||
| { status: "missing"; source: string; detail: string };
|
||||
|
|
@ -2063,18 +2075,61 @@ function enrichSkill(
|
|||
usedByAgents: CompanySkillUsageAgent[] = [],
|
||||
currentVersion: CompanySkillVersion | null = null,
|
||||
starredByCurrentActor = false,
|
||||
existingForks: CompanySkillForkSummary[] = [],
|
||||
) {
|
||||
const source = deriveSkillSourceInfo(skill);
|
||||
return {
|
||||
...skill,
|
||||
attachedAgentCount,
|
||||
usedByAgents,
|
||||
existingForks,
|
||||
currentVersion,
|
||||
starredByCurrentActor,
|
||||
...source,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeOriginalSkill(skill: CompanySkill): CompanySkillOriginalSummary {
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
sourceType: skill.sourceType,
|
||||
sourceLocator: skill.sourceLocator,
|
||||
sourceRef: skill.sourceRef,
|
||||
};
|
||||
}
|
||||
|
||||
function forkCreatedByActor(skill: CompanySkill, actor?: SkillActor | null) {
|
||||
const metadata = getSkillMeta(skill);
|
||||
if (actor?.type === "agent" && actor.agentId) {
|
||||
return asString(metadata.forkedByAgentId) === actor.agentId;
|
||||
}
|
||||
if (actor?.type === "user" && actor.userId) {
|
||||
return asString(metadata.forkedByUserId) === actor.userId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function summarizeForkSkill(
|
||||
skill: CompanySkill,
|
||||
actor: SkillActor | null | undefined,
|
||||
versionCount: number,
|
||||
): CompanySkillForkSummary {
|
||||
const metadata = getSkillMeta(skill);
|
||||
return {
|
||||
...summarizeOriginalSkill(skill),
|
||||
key: skill.key,
|
||||
forkedFromSkillId: skill.forkedFromSkillId,
|
||||
forkedFromCompanyId: skill.forkedFromCompanyId,
|
||||
currentVersionId: skill.currentVersionId,
|
||||
createdByCurrentActor: forkCreatedByActor(skill, actor),
|
||||
diverged: versionCount > 1 || Boolean(asString(metadata.userModifiedAt)),
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem {
|
||||
const source = deriveSkillSourceInfo(skill);
|
||||
const metadata = getSkillMeta(skill);
|
||||
|
|
@ -2495,20 +2550,66 @@ export function companySkillService(db: Db) {
|
|||
}));
|
||||
}
|
||||
|
||||
async function versionCount(companyId: string, skillId: string) {
|
||||
const [{ value }] = await db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(companySkillVersions)
|
||||
.where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId)));
|
||||
return Number(value ?? 0);
|
||||
}
|
||||
|
||||
async function existingForkSummaries(
|
||||
companyId: string,
|
||||
sourceSkillId: string,
|
||||
actor?: SkillActor | null,
|
||||
): Promise<CompanySkillForkSummary[]> {
|
||||
const rows = await db
|
||||
.select(selectCompanySkillColumns())
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.forkedFromSkillId, sourceSkillId)))
|
||||
.orderBy(desc(companySkills.updatedAt), asc(companySkills.name));
|
||||
const summaries: CompanySkillForkSummary[] = [];
|
||||
for (const row of rows) {
|
||||
const skill = toCompanySkill(row);
|
||||
summaries.push(summarizeForkSkill(skill, actor, await versionCount(companyId, skill.id)));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function detail(companyId: string, id: string, actor?: SkillActor | null): Promise<CompanySkillDetail | null> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const skill = await getById(companyId, id);
|
||||
if (!skill) return null;
|
||||
const usedByAgents = await usage(companyId, skill.key);
|
||||
const existingForks = await existingForkSummaries(companyId, skill.id, actor);
|
||||
return enrichSkill(
|
||||
skill,
|
||||
usedByAgents.length,
|
||||
usedByAgents,
|
||||
await getCurrentVersion(skill),
|
||||
await isStarredByActor(companyId, id, actor),
|
||||
existingForks,
|
||||
);
|
||||
}
|
||||
|
||||
async function forkPrecheck(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
actor?: SkillActor | null,
|
||||
): Promise<CompanySkillForkPrecheckResult | null> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const skill = await getById(companyId, skillId);
|
||||
if (!skill) return null;
|
||||
const usedByAgents = await usage(companyId, skill.key);
|
||||
return {
|
||||
skillId: skill.id,
|
||||
original: summarizeOriginalSkill(skill),
|
||||
agentUsageCount: usedByAgents.length,
|
||||
usedByAgents,
|
||||
existingForks: await existingForkSummaries(companyId, skill.id, actor),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectVersionFileInventory(
|
||||
companyId: string,
|
||||
skill: CompanySkill,
|
||||
|
|
@ -2759,12 +2860,136 @@ export function companySkillService(db: Db) {
|
|||
return toCompanySkillComment(row);
|
||||
}
|
||||
|
||||
async function planSkillReassignments(
|
||||
companyId: string,
|
||||
source: CompanySkill,
|
||||
forkKey: string,
|
||||
reassignAgentIds: string[] | undefined,
|
||||
): Promise<PlannedSkillReassignment[]> {
|
||||
const requestedAgentIds = Array.from(new Set(reassignAgentIds ?? []));
|
||||
if (requestedAgentIds.length === 0) return [];
|
||||
|
||||
const skills = await listReferenceTargets(companyId);
|
||||
const agentRows = await agents.list(companyId, { includeTerminated: true });
|
||||
const byId = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
const missingAgentIds = requestedAgentIds.filter((agentId) => !byId.has(agentId));
|
||||
if (missingAgentIds.length > 0) {
|
||||
throw notFound(`Agent not found for skill reassignment: ${missingAgentIds.join(", ")}`);
|
||||
}
|
||||
|
||||
return requestedAgentIds.map((agentId) => {
|
||||
const agent = byId.get(agentId)!;
|
||||
if (agent.companyId !== companyId) {
|
||||
throw unprocessable("Cannot reassign a skill for an agent in another company.", { agentId });
|
||||
}
|
||||
const adapterConfig = agent.adapterConfig as Record<string, unknown>;
|
||||
const desiredEntries = resolveDesiredSkillEntries(skills, adapterConfig);
|
||||
const hasSource = desiredEntries.some((entry) => entry.key === source.key);
|
||||
if (!hasSource) {
|
||||
throw unprocessable(`Agent "${agent.name}" does not currently use skill "${source.name}".`, {
|
||||
agentId,
|
||||
skillId: source.id,
|
||||
skillKey: source.key,
|
||||
});
|
||||
}
|
||||
return {
|
||||
agentId,
|
||||
reassignment: {
|
||||
agentId,
|
||||
previousSkillKey: source.key,
|
||||
nextSkillKey: forkKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function applySkillReassignments(
|
||||
companyId: string,
|
||||
source: CompanySkill,
|
||||
forkKey: string,
|
||||
planned: PlannedSkillReassignment[],
|
||||
): Promise<CompanySkillForkReassignment[]> {
|
||||
if (planned.length === 0) return [];
|
||||
const skills = await listReferenceTargets(companyId);
|
||||
await db.transaction(async (tx) => {
|
||||
for (const item of planned) {
|
||||
const row = await tx
|
||||
.select({
|
||||
id: agentsTable.id,
|
||||
name: agentsTable.name,
|
||||
adapterConfig: agentsTable.adapterConfig,
|
||||
})
|
||||
.from(agentsTable)
|
||||
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound(`Agent not found for skill reassignment: ${item.agentId}`);
|
||||
const adapterConfig = row.adapterConfig as Record<string, unknown>;
|
||||
const desiredEntries = resolveDesiredSkillEntries(skills, adapterConfig);
|
||||
const hasSource = desiredEntries.some((entry) => entry.key === source.key);
|
||||
if (!hasSource) {
|
||||
throw unprocessable(`Agent "${row.name}" does not currently use skill "${source.name}".`, {
|
||||
agentId: item.agentId,
|
||||
skillId: source.id,
|
||||
skillKey: source.key,
|
||||
});
|
||||
}
|
||||
const nextEntries = desiredEntries.map((entry) =>
|
||||
entry.key === source.key
|
||||
? { key: forkKey, versionId: null }
|
||||
: entry
|
||||
);
|
||||
const updated = await tx
|
||||
.update(agentsTable)
|
||||
.set({
|
||||
adapterConfig: writePaperclipSkillSyncPreference(adapterConfig, nextEntries),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(agentsTable.companyId, companyId), eq(agentsTable.id, item.agentId)))
|
||||
.returning({ id: agentsTable.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) throw notFound(`Agent not found for skill reassignment: ${item.agentId}`);
|
||||
}
|
||||
});
|
||||
return planned.map((item) => item.reassignment);
|
||||
}
|
||||
|
||||
async function cleanupFailedFork(companyId: string, sourceSkillId: string, forkSkillId: string, forkDir: string) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ currentVersionId: null, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.id, forkSkillId), eq(companySkills.companyId, companyId)));
|
||||
await tx
|
||||
.delete(companySkillComments)
|
||||
.where(and(eq(companySkillComments.companyId, companyId), eq(companySkillComments.companySkillId, forkSkillId)));
|
||||
await tx
|
||||
.delete(companySkillStars)
|
||||
.where(and(eq(companySkillStars.companyId, companyId), eq(companySkillStars.companySkillId, forkSkillId)));
|
||||
await tx
|
||||
.delete(companySkillVersions)
|
||||
.where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, forkSkillId)));
|
||||
await tx
|
||||
.delete(companySkills)
|
||||
.where(and(eq(companySkills.id, forkSkillId), eq(companySkills.companyId, companyId)));
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({
|
||||
forkCount: sql`greatest(${companySkills.forkCount} - 1, 0)`,
|
||||
installCount: sql`greatest(${companySkills.installCount} - 1, 0)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(companySkills.id, sourceSkillId), eq(companySkills.companyId, companyId)));
|
||||
});
|
||||
await fs.rm(forkDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function forkSkill(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
input: CompanySkillForkRequest = {},
|
||||
actor: SkillActor | null = null,
|
||||
): Promise<CompanySkill> {
|
||||
): Promise<CompanySkillForkResult> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const source = await getById(companyId, skillId);
|
||||
if (!source) throw notFound("Skill not found");
|
||||
|
|
@ -2772,6 +2997,8 @@ export function companySkillService(db: Db) {
|
|||
const usedSlugs = new Set(existing.map((skill) => normalizeSkillSlug(skill.slug) ?? skill.slug));
|
||||
const forkSlug = uniqueSkillSlug(normalizeSkillSlug(input.slug ?? `${source.slug}-fork`) ?? `${source.slug}-fork`, usedSlugs);
|
||||
const forkName = input.name?.trim() || `${source.name} Fork`;
|
||||
const forkKey = `company/${companyId}/${forkSlug}`;
|
||||
const plannedReassignments = await planSkillReassignments(companyId, source, forkKey, input.reassignAgentIds);
|
||||
const managedRoot = resolveManagedSkillsRoot(companyId);
|
||||
const forkDir = path.resolve(managedRoot, forkSlug);
|
||||
await fs.rm(forkDir, { recursive: true, force: true });
|
||||
|
|
@ -2798,7 +3025,7 @@ export function companySkillService(db: Db) {
|
|||
forkedByUserId: actor?.type === "user" ? actor.userId ?? null : null,
|
||||
};
|
||||
const imported = await upsertImportedSkills(companyId, [{
|
||||
key: `company/${companyId}/${forkSlug}`,
|
||||
key: forkKey,
|
||||
slug: forkSlug,
|
||||
name: asString(parsed.frontmatter.name) ?? forkName,
|
||||
description: asString(parsed.frontmatter.description) ?? source.description,
|
||||
|
|
@ -2836,10 +3063,22 @@ export function companySkillService(db: Db) {
|
|||
})
|
||||
.where(and(eq(companySkills.id, source.id), eq(companySkills.companyId, companyId)));
|
||||
await createVersion(companyId, forked.id, { label: "Initial version" }, actor);
|
||||
return getById(companyId, forked.id).then((skill) => {
|
||||
const persistedFork = await getById(companyId, forked.id).then((skill) => {
|
||||
if (!skill) throw notFound("Forked skill not found");
|
||||
return skill;
|
||||
});
|
||||
let reassignments: CompanySkillForkReassignment[];
|
||||
try {
|
||||
reassignments = await applySkillReassignments(companyId, source, forkKey, plannedReassignments);
|
||||
} catch (error) {
|
||||
await cleanupFailedFork(companyId, source.id, forked.id, forkDir);
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
skill: persistedFork,
|
||||
original: summarizeOriginalSkill(source),
|
||||
reassignments,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateStatus(companyId: string, skillId: string): Promise<CompanySkillUpdateStatus | null> {
|
||||
|
|
@ -4463,6 +4702,7 @@ export function companySkillService(db: Db) {
|
|||
},
|
||||
categoryCounts,
|
||||
detail,
|
||||
forkPrecheck,
|
||||
listVersions,
|
||||
getVersion,
|
||||
createVersion,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ function makeDetail(currentVersion: CompanySkillVersion, overrides: Partial<Comp
|
|||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
attachedAgentCount: 0,
|
||||
usedByAgents: [],
|
||||
existingForks: [],
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Local",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const MOCK_DETAIL: CompanySkillDetail = {
|
|||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
attachedAgentCount: 4,
|
||||
existingForks: [],
|
||||
usedByAgents: [
|
||||
{ id: "a-1", name: "Astra", urlKey: "astra", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-2", name: "Scout", urlKey: "scout", adapterType: "http", desired: true, actualState: null, versionId: null },
|
||||
|
|
|
|||
Loading…
Reference in New Issue