diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e724fd16b6..f771ed6cf2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -455,6 +455,11 @@ export type { CompanySkillCommentCreateRequest, CompanySkillCommentUpdateRequest, CompanySkillForkRequest, + CompanySkillOriginalSummary, + CompanySkillForkSummary, + CompanySkillForkReassignment, + CompanySkillForkResult, + CompanySkillForkPrecheckResult, CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index a2d3e6b9f2..4573ecc816 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -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 { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0d27034914..c0245d1f84 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -77,6 +77,11 @@ export type { CompanySkillCommentCreateRequest, CompanySkillCommentUpdateRequest, CompanySkillForkRequest, + CompanySkillOriginalSummary, + CompanySkillForkSummary, + CompanySkillForkReassignment, + CompanySkillForkResult, + CompanySkillForkPrecheckResult, CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillAuditSeverity, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index ff223dd60f..793f5671a1 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -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(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 1dc44e0592..1d3d3b4284 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,6 +119,11 @@ export { companySkillCommentCreateSchema, companySkillCommentUpdateSchema, companySkillForkSchema, + companySkillOriginalSummarySchema, + companySkillForkSummarySchema, + companySkillForkReassignmentSchema, + companySkillForkResultSchema, + companySkillForkPrecheckResultSchema, companySkillUpdateSchema, companySkillUpdateStatusSchema, companySkillAuditFindingSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index ca8abc4d21..c964c4b15e 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -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", }); diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 81d9158950..e4a01860c0 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -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; + const keepConfig = afterAgents.find((agent) => agent.id === keepAgentId)?.adapterConfig as Record; + 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(); diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index cc32d23890..be561dbbba 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -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); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index a58e7a6c24..78111febfc 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -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); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 69511eae35..c6860154d8 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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"], diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index e0cfc930b6..6d23e80861 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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`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 { + 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 { 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 { + 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 { + 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; + 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 { + 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; + 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 { + ): Promise { 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 { @@ -4463,6 +4702,7 @@ export function companySkillService(db: Db) { }, categoryCounts, detail, + forkPrecheck, listVersions, getVersion, createVersion, diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 988d088aee..5770f7fa4f 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -158,6 +158,7 @@ function makeDetail(currentVersion: CompanySkillVersion, overrides: Partial