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 <noreply@paperclip.ing>
This commit is contained in:
2026-04-13 00:31:37 +00:00
parent 3610559cc7
commit e739a2d130
8 changed files with 92 additions and 1 deletions
+2
View File
@@ -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,
@@ -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 {
+1
View File
@@ -28,6 +28,7 @@ export type {
CompanySkillProjectScanRequest,
CompanySkillProjectScanSkipped,
CompanySkillProjectScanConflict,
CompanySkillProjectScanPruned,
CompanySkillProjectScanResult,
CompanySkillCreateRequest,
CompanySkillFileDetail,
@@ -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({
+1
View File
@@ -48,6 +48,7 @@ export {
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
companySkillProjectScanPrunedSchema,
companySkillProjectScanResultSchema,
companySkillCreateSchema,
companySkillFileDetailSchema,
@@ -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<string, unknown>;
const syncConfig = config.paperclipSkillSync as Record<string, unknown>;
const desiredSkills = syncConfig.desiredSkills as string[];
expect(desiredSkills).toContain("test-org/test-skills/prune-skill");
});
});
@@ -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({
+18 -1
View File
@@ -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,
};
}