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:
Dotta 2026-07-08 14:22:39 -05:00 committed by GitHub
parent f616b6746c
commit 4a7a732476
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 611 additions and 16 deletions

View File

@ -455,6 +455,11 @@ export type {
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillOriginalSummary,
CompanySkillForkSummary,
CompanySkillForkReassignment,
CompanySkillForkResult,
CompanySkillForkPrecheckResult,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,

View File

@ -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 {

View File

@ -77,6 +77,11 @@ export type {
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillForkRequest,
CompanySkillOriginalSummary,
CompanySkillForkSummary,
CompanySkillForkReassignment,
CompanySkillForkResult,
CompanySkillForkPrecheckResult,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillAuditSeverity,

View File

@ -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(),

View File

@ -119,6 +119,11 @@ export {
companySkillCommentCreateSchema,
companySkillCommentUpdateSchema,
companySkillForkSchema,
companySkillOriginalSummarySchema,
companySkillForkSummarySchema,
companySkillForkReassignmentSchema,
companySkillForkResultSchema,
companySkillForkPrecheckResultSchema,
companySkillUpdateSchema,
companySkillUpdateStatusSchema,
companySkillAuditFindingSchema,

View File

@ -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",
});

View File

@ -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();

View File

@ -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);

View File

@ -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);

View File

@ -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"],

View File

@ -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,

View File

@ -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",

View File

@ -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 },