From e739a2d130b9c2b8197fe90fc6382947a197a073 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Mon, 13 Apr 2026 00:31:37 +0000 Subject: [PATCH] feat(skills): add dryRun flag for scan prune path Add a `dryRun` option to the scan-projects endpoint. When true, the scan identifies which skills would be pruned and which agents would be affected, but does not delete anything or modify agent configs. The response now includes: - `pruned[]`: list of skills that would be (or were) removed, with affected agent names - `dryRun`: boolean echoed back so callers can distinguish preview results from live mutations This lets callers preview destructive prune operations before committing to them, addressing the review concern about silent deletion of production data. Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/types/company-skill.ts | 11 +++++ packages/shared/src/types/index.ts | 1 + .../shared/src/validators/company-skill.ts | 11 +++++ packages/shared/src/validators/index.ts | 1 + .../__tests__/company-skills-prune.test.ts | 42 +++++++++++++++++++ .../__tests__/company-skills-routes.test.ts | 6 +++ server/src/services/company-skills.ts | 19 ++++++++- 8 files changed, 92 insertions(+), 1 deletion(-) 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, }; }