diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 83e5ff79..72a522f4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -176,6 +176,7 @@ export type { CompanySkillProjectScanRequest, CompanySkillProjectScanSkipped, CompanySkillProjectScanConflict, + CompanySkillProjectScanPruned, CompanySkillProjectScanResult, CompanySkillCreateRequest, CompanySkillFileDetail, @@ -561,6 +562,7 @@ export { companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, + companySkillProjectScanPrunedSchema, companySkillProjectScanResultSchema, companySkillCreateSchema, companySkillFileDetailSchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 12834083..69edaa1f 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -93,6 +93,7 @@ export interface CompanySkillImportResult { export interface CompanySkillProjectScanRequest { projectIds?: string[]; workspaceIds?: string[]; + dryRun?: boolean; } export interface CompanySkillProjectScanSkipped { @@ -118,6 +119,14 @@ export interface CompanySkillProjectScanConflict { reason: string; } +export interface CompanySkillProjectScanPruned { + skillId: string; + slug: string; + key: string; + sourceLocator: string | null; + affectedAgents: string[]; +} + export interface CompanySkillProjectScanResult { scannedProjects: number; scannedWorkspaces: number; @@ -126,7 +135,9 @@ export interface CompanySkillProjectScanResult { updated: CompanySkill[]; skipped: CompanySkillProjectScanSkipped[]; conflicts: CompanySkillProjectScanConflict[]; + pruned: CompanySkillProjectScanPruned[]; warnings: string[]; + dryRun: boolean; } export interface CompanySkillCreateRequest { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 888740d3..d1c56aca 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -28,6 +28,7 @@ export type { CompanySkillProjectScanRequest, CompanySkillProjectScanSkipped, CompanySkillProjectScanConflict, + CompanySkillProjectScanPruned, CompanySkillProjectScanResult, CompanySkillCreateRequest, CompanySkillFileDetail, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 58b9165d..8739ccf4 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -76,6 +76,7 @@ export const companySkillUpdateAuthSchema = z.object({ export const companySkillProjectScanRequestSchema = z.object({ projectIds: z.array(z.string().uuid()).optional(), workspaceIds: z.array(z.string().uuid()).optional(), + dryRun: z.boolean().optional(), }); export const companySkillProjectScanSkippedSchema = z.object({ @@ -101,6 +102,14 @@ export const companySkillProjectScanConflictSchema = z.object({ reason: z.string().min(1), }); +export const companySkillProjectScanPrunedSchema = z.object({ + skillId: z.string().uuid(), + slug: z.string().min(1), + key: z.string().min(1), + sourceLocator: z.string().nullable(), + affectedAgents: z.array(z.string()), +}); + export const companySkillProjectScanResultSchema = z.object({ scannedProjects: z.number().int().nonnegative(), scannedWorkspaces: z.number().int().nonnegative(), @@ -109,7 +118,9 @@ export const companySkillProjectScanResultSchema = z.object({ updated: z.array(companySkillSchema), skipped: z.array(companySkillProjectScanSkippedSchema), conflicts: z.array(companySkillProjectScanConflictSchema), + pruned: z.array(companySkillProjectScanPrunedSchema), warnings: z.array(z.string()), + dryRun: z.boolean(), }); export const companySkillCreateSchema = z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d1bc3929..7e72c9fb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -48,6 +48,7 @@ export { companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, + companySkillProjectScanPrunedSchema, companySkillProjectScanResultSchema, companySkillCreateSchema, companySkillFileDetailSchema, diff --git a/server/src/__tests__/company-skills-prune.test.ts b/server/src/__tests__/company-skills-prune.test.ts index a75ab072..3ccc8612 100644 --- a/server/src/__tests__/company-skills-prune.test.ts +++ b/server/src/__tests__/company-skills-prune.test.ts @@ -282,4 +282,46 @@ describeEmbeddedPostgres("scanProjectWorkspaces prune path", () => { ]), ); }); + + it("reports pruned skills without deleting when dryRun is true", async () => { + stubGitHubSource(["keep-skill"]); + + const { companySkillService } = await import("../services/company-skills.js"); + const svc = companySkillService(db); + const result = await svc.scanProjectWorkspaces(companyId, { dryRun: true }); + + // The result should flag dryRun and list what would be pruned + expect(result.dryRun).toBe(true); + expect(result.pruned).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + slug: "prune-skill", + affectedAgents: expect.arrayContaining(["Builder"]), + }), + ]), + ); + + // No warnings emitted (nothing was actually deleted) + const pruneWarnings = result.warnings.filter((w) => w.includes("prune-skill")); + expect(pruneWarnings).toHaveLength(0); + + // Both skills should still exist in the database + const remaining = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)); + const remainingSlugs = remaining.map((r) => r.slug); + expect(remainingSlugs).toContain("keep-skill"); + expect(remainingSlugs).toContain("prune-skill"); + + // Agent config should be unchanged + const [agentRow] = await db + .select() + .from(agents) + .where(eq(agents.id, agentId)); + const config = agentRow!.adapterConfig as Record; + const syncConfig = config.paperclipSkillSync as Record; + const desiredSkills = syncConfig.desiredSkills as string[]; + expect(desiredSkills).toContain("test-org/test-skills/prune-skill"); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 32656226..a7a8766e 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -78,7 +78,9 @@ describe("company skill mutation permissions", () => { updated: [], skipped: [], conflicts: [], + pruned: [], warnings: [], + dryRun: false, }); mockLogActivity.mockResolvedValue(undefined); mockAccessService.canUser.mockResolvedValue(true); @@ -293,9 +295,13 @@ describe("company skill mutation permissions", () => { updated: [], skipped: [], conflicts: [], + pruned: [ + { skillId: "skill-1", slug: "ghost-skill", key: "vercel-labs/agent-browser/ghost-skill", sourceLocator: "https://github.com/vercel-labs/agent-browser", affectedAgents: ["Builder"] }, + ], warnings: [ 'Skill "ghost-skill" was removed from https://github.com/vercel-labs/agent-browser and detached from Builder.', ], + dryRun: false, }); const res = await request(await createApp({ diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d1fea3d9..adeb537a 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -17,6 +17,7 @@ import type { CompanySkillImportResult, CompanySkillListItem, CompanySkillProjectScanConflict, + CompanySkillProjectScanPruned, CompanySkillProjectScanRequest, CompanySkillProjectScanResult, CompanySkillProjectScanSkipped, @@ -1860,8 +1861,10 @@ export function companySkillService(db: Db) { ? await projects.listByIds(companyId, input.projectIds) : await projects.list(companyId); const workspaceFilter = new Set(input.workspaceIds ?? []); + const dryRun = input.dryRun === true; const skipped: CompanySkillProjectScanSkipped[] = []; const conflicts: CompanySkillProjectScanConflict[] = []; + const pruned: CompanySkillProjectScanPruned[] = []; const warnings: string[] = []; const imported: CompanySkill[] = []; const updated: CompanySkill[] = []; @@ -2043,6 +2046,18 @@ export function companySkillService(db: Db) { for (const skill of skillsAtSource) { if (currentSlugs.has(skill.slug)) continue; const usedByAgents = await usage(companyId, skill.key); + const affectedAgentNames = usedByAgents.map((a) => a.name); + + pruned.push({ + skillId: skill.id, + slug: skill.slug, + key: skill.key, + sourceLocator: skill.sourceLocator, + affectedAgents: affectedAgentNames, + }); + + if (dryRun) continue; + if (usedByAgents.length > 0) { // Detach the skill from all agents that have it, then delete for (const agent of usedByAgents) { @@ -2059,7 +2074,7 @@ export function companySkillService(db: Db) { } } warnings.push( - `Skill "${skill.slug}" was removed from ${sourceLocator} and detached from ${usedByAgents.map((a) => a.name).join(", ")}.`, + `Skill "${skill.slug}" was removed from ${sourceLocator} and detached from ${affectedAgentNames.join(", ")}.`, ); } else { warnings.push( @@ -2082,7 +2097,9 @@ export function companySkillService(db: Db) { updated, skipped, conflicts, + pruned, warnings, + dryRun, }; }