From c29890a016fc31a82ab72444b7d71c6fda51c7b0 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:13:39 +0800 Subject: [PATCH] feat: add global skill uninstall flow --- .../skills/_components/skills-library.tsx | 55 ++++-- app/(dashboard)/skills/page.tsx | 2 +- docs/prds/README.md | 5 +- .../global-skill-enablement-control-flow.md | 28 ++- .../uninstall-skill-control-flow.md | 26 ++- lib/actions/skill.ts | 21 ++- lib/jobs/project-task/executors/index.ts | 3 + .../project-task/executors/uninstall-skill.ts | 121 ++++++++++++ lib/jobs/project-task/projectTaskReconcile.ts | 70 ++++++- lib/platform/control/commands/skill/index.ts | 1 + .../commands/skill/uninstall-global-skill.ts | 178 ++++++++++++++++++ .../create-uninstall-skill-task.ts | 48 +++++ lib/repo/project-task.ts | 19 ++ lib/skills/catalog.ts | 2 + 14 files changed, 547 insertions(+), 32 deletions(-) rename docs/prds/{ => skills}/global-skill-enablement-control-flow.md (89%) rename docs/prds/{ => skills}/uninstall-skill-control-flow.md (90%) create mode 100644 lib/jobs/project-task/executors/uninstall-skill.ts create mode 100644 lib/platform/control/commands/skill/uninstall-global-skill.ts create mode 100644 lib/platform/persistence/project-task/create-uninstall-skill-task.ts diff --git a/app/(dashboard)/skills/_components/skills-library.tsx b/app/(dashboard)/skills/_components/skills-library.tsx index d303f9c..4851df2 100644 --- a/app/(dashboard)/skills/_components/skills-library.tsx +++ b/app/(dashboard)/skills/_components/skills-library.tsx @@ -15,7 +15,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { enableGlobalSkill } from '@/lib/actions/skill' +import { enableGlobalSkill, uninstallGlobalSkill } from '@/lib/actions/skill' import { getSkillCatalog } from '@/lib/skills/catalog' type SkillsLibraryProps = { @@ -24,25 +24,45 @@ type SkillsLibraryProps = { export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) { const router = useRouter() - const [pendingSkillId, setPendingSkillId] = useState(null) + const [pendingOperation, setPendingOperation] = useState<{ + skillId: string + type: 'enable' | 'uninstall' + } | null>(null) const [isPending, startTransition] = useTransition() const catalog = getSkillCatalog() const enabledSkillSet = new Set(enabledSkillIds) const handleEnable = (skillId: string) => { startTransition(async () => { - setPendingSkillId(skillId) + setPendingOperation({ skillId, type: 'enable' }) const result = await enableGlobalSkill(skillId) if (!result.success) { toast.error(result.error) - setPendingSkillId(null) + setPendingOperation(null) return } toast.success('Global skill enabled. Install tasks will fan out across your projects.') router.refresh() - setPendingSkillId(null) + setPendingOperation(null) + }) + } + + const handleUninstall = (skillId: string) => { + startTransition(async () => { + setPendingOperation({ skillId, type: 'uninstall' }) + + const result = await uninstallGlobalSkill(skillId) + if (!result.success) { + toast.error(result.error) + setPendingOperation(null) + return + } + + toast.success('Global skill removed. Uninstall tasks will converge existing projects.') + router.refresh() + setPendingOperation(null) }) } @@ -55,9 +75,10 @@ export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {

Skills

- Enabling a skill here creates global desired state for the user. Existing projects get - `INSTALL_SKILL` tasks immediately, and new projects inherit the same skill when they - are created. + Skills here define global desired state for the user. Enabling fans out + `INSTALL_SKILL` tasks to existing projects and future projects inherit the skill. + Uninstalling removes the global desired state and fans out `UNINSTALL_SKILL` work + without auto-starting stopped sandboxes.

@@ -65,7 +86,8 @@ export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
{catalog.map((skill) => { const isEnabled = enabledSkillSet.has(skill.skillId) - const isLoading = isPending && pendingSkillId === skill.skillId + const isLoading = isPending && pendingOperation?.skillId === skill.skillId + const isUninstalling = isLoading && pendingOperation?.type === 'uninstall' return ( @@ -108,10 +130,19 @@ export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) { diff --git a/app/(dashboard)/skills/page.tsx b/app/(dashboard)/skills/page.tsx index cede1ac..1f34fe0 100644 --- a/app/(dashboard)/skills/page.tsx +++ b/app/(dashboard)/skills/page.tsx @@ -7,7 +7,7 @@ import { SkillsLibrary } from './_components/skills-library' export const metadata = { title: 'Skills | Fulling', - description: 'Enable global skills that will be installed across your projects.', + description: 'Manage global skills that install into or uninstall from your projects.', } export default async function SkillsPage() { diff --git a/docs/prds/README.md b/docs/prds/README.md index e1c0fca..e3b602d 100644 --- a/docs/prds/README.md +++ b/docs/prds/README.md @@ -41,6 +41,7 @@ Recommended split: - One PRD per feature or workflow - Use stable kebab-case file names - Prefer names like `import-project-control-flow.md` +- Related PRDs may be grouped under a feature directory such as `skills/` ## Suggested PRD structure @@ -59,5 +60,5 @@ Each PRD should usually include: ## Current PRDs - [Import Project Control Flow](./import-project-control-flow.md) -- [Global Skill Enablement Control Flow](./global-skill-enablement-control-flow.md) -- [Uninstall Skill Control Flow](./uninstall-skill-control-flow.md) +- [Skills / Global Skill Enablement Control Flow](./skills/global-skill-enablement-control-flow.md) +- [Skills / Uninstall Skill Control Flow](./skills/uninstall-skill-control-flow.md) diff --git a/docs/prds/global-skill-enablement-control-flow.md b/docs/prds/skills/global-skill-enablement-control-flow.md similarity index 89% rename from docs/prds/global-skill-enablement-control-flow.md rename to docs/prds/skills/global-skill-enablement-control-flow.md index 57c73ba..fb40255 100644 --- a/docs/prds/global-skill-enablement-control-flow.md +++ b/docs/prds/skills/global-skill-enablement-control-flow.md @@ -1,6 +1,6 @@ # Global Skill Enablement Control Flow -Status: Draft +Status: Implemented ## Goal @@ -36,6 +36,12 @@ This document does not define: - auto-starting stopped projects only to install skills - detailed project-level skill UI beyond required status semantics +Current phase note: + +- the skill directory is a static local catalog +- users do not create custom skills from the UI in this phase +- the catalog is not yet backed by a remote marketplace or external source + ## User Intent When a user enables a skill from the global Skills tab, the system receives three @@ -95,6 +101,10 @@ control plane has persisted installation work for that project. For a currently running project, this normally means an `INSTALL_SKILL` task can proceed immediately. +For the current implementation, the control plane should also proactively trigger +task evaluation for projects that are already `RUNNING`, rather than waiting only +for the next periodic reconcile cycle. + For a stopped or otherwise non-runnable project, this means an `INSTALL_SKILL` task may remain pending until prerequisites are satisfied. @@ -221,6 +231,7 @@ This allows valid combinations such as: For the current phase of the product: - the global Skills tab should reflect user-level enablement state +- the global Skills tab is backed by a static local catalog in this phase - enabling a skill should return quickly after durable state is created - the UI should not wait for all projects to finish installation before showing the skill as enabled - stopped projects should not be presented as immediate installation failures @@ -291,6 +302,8 @@ For the current phase of the product: - project install tasks should receive `installCommand` in their payload - install execution should consume the command from task payload rather than inferring installation behavior dynamically at runtime +- `installCommand` must be non-interactive so sandbox execution can complete + without user input - the current phase does not define editing an existing enabled skill's `installCommand` @@ -311,15 +324,22 @@ This PRD does not define: Current implementation should preserve this product contract: - the user action enables a skill at the global user scope, not the single-project scope +- the current Skills page reads from a static local catalog, not a user-authored + or remote marketplace-backed catalog - `UserSkill` is the durable source of truth for the enabled skill and its `installCommand` - project installation is asynchronous and should run through `ProjectTask` - each `INSTALL_SKILL` task should contain an execution snapshot of the `installCommand` - install execution should happen only when a project's sandbox is `RUNNING` +- `installCommand` should be written in non-interactive form - sandbox lifecycle state remains separate from skill installation state - future project creation should consult globally enabled skills and create install work automatically +- already-`RUNNING` projects should be triggered immediately after enablement so + installation does not rely only on cron pickup Current codebase note: -- `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL` -- task prerequisite evaluation already matches the desired sandbox `RUNNING` gate -- the install-skill executor and global enabled-skill persistence model are not yet implemented +- `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented +- task prerequisite evaluation matches the desired sandbox `RUNNING` gate +- import projects additionally wait for successful repository clone before skill installation +- the current catalog entry uses a non-interactive command form: + `npx -y skills add https://github.com/anthropics/skills --skill frontend-design -y` diff --git a/docs/prds/uninstall-skill-control-flow.md b/docs/prds/skills/uninstall-skill-control-flow.md similarity index 90% rename from docs/prds/uninstall-skill-control-flow.md rename to docs/prds/skills/uninstall-skill-control-flow.md index a5560fa..c6be4a6 100644 --- a/docs/prds/uninstall-skill-control-flow.md +++ b/docs/prds/skills/uninstall-skill-control-flow.md @@ -1,6 +1,6 @@ # Uninstall Skill Control Flow -Status: Draft +Status: Implemented ## Goal @@ -39,6 +39,12 @@ This document does not define: - auto-starting stopped projects only to uninstall skills - bulk operator tooling for failed uninstalls +Current phase note: + +- the skill directory is a static local catalog +- users do not create custom skills from the UI in this phase +- the catalog is not yet backed by a remote marketplace or external source + ## User Intent When a user uninstalls a skill from the global Skills tab, the system receives @@ -101,6 +107,10 @@ skill indefinitely. For a currently running project, this normally means an `UNINSTALL_SKILL` task can proceed immediately. +For the current implementation, the control plane should also proactively trigger +task evaluation for projects that are already `RUNNING`, rather than waiting only +for the next periodic reconcile cycle. + For a stopped or otherwise non-runnable project, this means uninstall work may remain pending until prerequisites are satisfied. @@ -253,6 +263,7 @@ For the current phase of the product: - the global Skills tab should stop showing the skill as enabled once durable uninstall state is created +- the global Skills tab is backed by a static local catalog in this phase - the UI should not wait for all projects to finish uninstall before reflecting the global uninstall - stopped projects should not be presented as immediate uninstall failures @@ -296,6 +307,7 @@ This currently implies the need for: - `userSkillId` - `skillId` - `installCommand` + - `uninstallCommand` - task result or error data that records project-level uninstall outcome If uninstall fails for one project, the database must still clearly reflect: @@ -321,9 +333,11 @@ For the current phase of the product: - each globally enabled skill is represented by a `UserSkill` record that includes `installCommand` +- the static catalog also defines the `uninstallCommand` used for removal - uninstall removes that global `UserSkill` desired state - historical uninstall and install tasks may still retain `installCommand` in their payload as execution snapshots +- uninstall tasks should receive `uninstallCommand` in their payload as an execution snapshot - uninstall execution should rely on task payload and task semantics rather than attempting to rebuild prior install intent from mutable runtime state @@ -344,6 +358,8 @@ This PRD does not define: Current implementation should preserve this product contract: - the user action removes a skill at the global user scope, not the single-project scope +- the current Skills page reads from a static local catalog, not a user-authored + or remote marketplace-backed catalog - the removed global skill record is the `UserSkill` source of truth that previously held the skill's `installCommand` - project uninstall is asynchronous and should run through `ProjectTask` @@ -358,6 +374,10 @@ Current implementation should preserve this product contract: Current codebase note: - `ProjectTaskType` already reserves `INSTALL_SKILL` and `UNINSTALL_SKILL` +- enable-side `UserSkill` persistence and `INSTALL_SKILL` fan-out are implemented - task prerequisite evaluation already matches the desired sandbox `RUNNING` gate -- uninstall executor, task supersession rules, and global enabled-skill persistence - model are not yet implemented +- global uninstall removes the `UserSkill` desired state and fans out uninstall work +- pending and waiting install tasks for the same skill are cancelled when uninstall is accepted +- stale install work is prevented from winning over newer uninstall intent during task reconcile +- projects that are already `RUNNING` are triggered immediately after uninstall fan-out +- uninstall executor, uninstall UI entry, and global uninstall control flow are implemented diff --git a/lib/actions/skill.ts b/lib/actions/skill.ts index c902bcf..e7e9709 100644 --- a/lib/actions/skill.ts +++ b/lib/actions/skill.ts @@ -1,13 +1,11 @@ 'use server' -import type { UserSkill } from '@prisma/client' - import { auth } from '@/lib/auth' -import { enableGlobalSkillCommand } from '@/lib/platform/control/commands/skill' +import { enableGlobalSkillCommand, uninstallGlobalSkillCommand } from '@/lib/platform/control/commands/skill' import type { ActionResult } from './types' -export async function enableGlobalSkill(skillId: string): Promise> { +export async function enableGlobalSkill(skillId: string): Promise> { const session = await auth() if (!session) { @@ -19,3 +17,18 @@ export async function enableGlobalSkill(skillId: string): Promise> { + const session = await auth() + + if (!session) { + return { success: false, error: 'Unauthorized' } + } + + return uninstallGlobalSkillCommand({ + userId: session.user.id, + skillId, + }) +} diff --git a/lib/jobs/project-task/executors/index.ts b/lib/jobs/project-task/executors/index.ts index 6ce0736..993a37c 100644 --- a/lib/jobs/project-task/executors/index.ts +++ b/lib/jobs/project-task/executors/index.ts @@ -4,6 +4,7 @@ import type { ProjectTaskWithRelations } from '@/lib/repo/project-task' import { type ProjectTaskExecutorResult, runCloneRepositoryTask } from './clone-repository' import { runInstallSkillTask } from './install-skill' +import { runUninstallSkillTask } from './uninstall-skill' export async function runProjectTaskExecutor( task: ProjectTaskWithRelations @@ -13,6 +14,8 @@ export async function runProjectTaskExecutor( return runCloneRepositoryTask(task) case 'INSTALL_SKILL': return runInstallSkillTask(task) + case 'UNINSTALL_SKILL': + return runUninstallSkillTask(task) default: return { success: false, diff --git a/lib/jobs/project-task/executors/uninstall-skill.ts b/lib/jobs/project-task/executors/uninstall-skill.ts new file mode 100644 index 0000000..e0ae7fa --- /dev/null +++ b/lib/jobs/project-task/executors/uninstall-skill.ts @@ -0,0 +1,121 @@ +import type { Prisma } from '@prisma/client' + +import type { ProjectTaskWithRelations } from '@/lib/repo/project-task' +import { getLatestSuccessfulCloneTask } from '@/lib/repo/project-task' +import { getSandboxTtydContext } from '@/lib/util/ttyd-context' +import { execCommand } from '@/lib/util/ttyd-exec' + +import type { ProjectTaskExecutorResult } from './clone-repository' + +const SKILL_UNINSTALL_EXEC_TIMEOUT_MS = parseInt( + process.env.PROJECT_SKILL_UNINSTALL_EXEC_TIMEOUT_MS || '120000', + 10 +) + +type UninstallSkillPayload = { + skillId?: string + installCommand?: string + uninstallCommand?: string +} + +type CloneTaskResult = { + importPath?: string +} + +/** + * Executes a skill uninstall command inside the project's sandbox. + * + * Expected inputs: + * - A `UNINSTALL_SKILL` task with a valid `uninstallCommand` payload. + * - A sandbox already verified as runnable by task prerequisite evaluation. + * + * Expected outputs: + * - Runs the command inside the sandbox and returns task result metadata. + * + * Out of scope: + * - Does not decide whether the skill should still exist for the project. + * - Does not auto-start stopped sandboxes to perform removal. + */ +export async function runUninstallSkillTask( + task: ProjectTaskWithRelations +): Promise { + const payload = (task.payload ?? {}) as UninstallSkillPayload + const skillId = payload.skillId + const installCommand = payload.installCommand?.trim() + const uninstallCommand = payload.uninstallCommand?.trim() + + if (!skillId || !installCommand || !uninstallCommand) { + return { + success: false, + error: 'Missing uninstall skill payload', + retryable: false, + } + } + + if (!task.sandbox?.id) { + return { + success: false, + error: 'Sandbox not found for uninstall task', + retryable: false, + } + } + + try { + const workingDirectory = await resolveSkillWorkingDirectory(task) + const { ttyd } = await getSandboxTtydContext(task.sandbox.id, task.project.user.id) + + const command = [ + 'set -e', + `cd '${shellEscapeSingleQuoted(workingDirectory)}'`, + uninstallCommand, + ].join(' && ') + + await execCommand( + ttyd.baseUrl, + ttyd.accessToken, + command, + SKILL_UNINSTALL_EXEC_TIMEOUT_MS, + ttyd.authorization + ) + + return { + success: true, + result: { + skillId, + installCommand, + uninstallCommand, + workingDirectory, + } satisfies Prisma.InputJsonValue, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + retryable: true, + } + } +} + +async function resolveSkillWorkingDirectory(task: ProjectTaskWithRelations): Promise { + const defaultDirectory = '/home/fulling/next' + + if (!task.project.githubRepoFullName) { + return defaultDirectory + } + + const cloneTask = await getLatestSuccessfulCloneTask(task.projectId) + if (!cloneTask) { + throw new Error('Imported repository is not available for skill uninstallation') + } + + const cloneResult = (cloneTask.result ?? {}) as CloneTaskResult + if (!cloneResult.importPath) { + throw new Error('Clone task result is missing importPath') + } + + return `${defaultDirectory}/${cloneResult.importPath}` +} + +function shellEscapeSingleQuoted(input: string): string { + return input.replace(/'/g, `'\\''`) +} diff --git a/lib/jobs/project-task/projectTaskReconcile.ts b/lib/jobs/project-task/projectTaskReconcile.ts index dd776a7..bb175ae 100644 --- a/lib/jobs/project-task/projectTaskReconcile.ts +++ b/lib/jobs/project-task/projectTaskReconcile.ts @@ -3,6 +3,7 @@ import { Cron } from 'croner' import { logger as baseLogger } from '@/lib/logger' import { acquireAndLockProjectTasks, + findRunningInstallSkillTaskCreatedBefore, getLatestProjectTask, getProjectTaskById, getRunnableTasksForProject, @@ -85,9 +86,9 @@ async function handleProjectTask( ): Promise { const prerequisites = await evaluateTaskPrerequisites(task) if (!prerequisites.ready) { - if (prerequisites.terminalFailure) { + if (prerequisites.terminalStatus) { await setProjectTaskState(task.id, { - status: 'FAILED', + status: prerequisites.terminalStatus, error: prerequisites.reason ?? 'Task prerequisites can no longer be satisfied', lockedUntil: null, finishedAt: new Date(), @@ -122,6 +123,22 @@ async function handleProjectTask( const attemptCount = await incrementProjectTaskAttemptCount(task.id) const result = await runProjectTaskExecutor(executingTask) + const latestTask = await getProjectTaskById(task.id) + if (!latestTask) { + return + } + + if (latestTask.type === 'INSTALL_SKILL' && !latestTask.userSkillId) { + await setProjectTaskState(task.id, { + status: 'CANCELLED', + error: 'Superseded by global uninstall', + lockedUntil: null, + finishedAt: new Date(), + attemptCount, + }) + return + } + if (result.success) { await setProjectTaskState(task.id, { status: 'SUCCEEDED', @@ -155,11 +172,13 @@ async function handleProjectTask( } async function evaluateTaskPrerequisites( - task: Pick -): Promise<{ ready: boolean; reason?: string; terminalFailure?: boolean }> { + task: Pick< + ProjectTaskWithRelations, + 'id' | 'projectId' | 'type' | 'sandbox' | 'project' | 'skillId' | 'createdAt' | 'userSkillId' + > +): Promise<{ ready: boolean; reason?: string; terminalStatus?: 'FAILED' | 'CANCELLED' }> { switch (task.type) { case 'CLONE_REPOSITORY': - case 'UNINSTALL_SKILL': case 'DEPLOY_PROJECT': if (!task.sandbox) { return { ready: false, reason: 'Sandbox not found' } @@ -170,8 +189,47 @@ async function evaluateTaskPrerequisites( reason: `Waiting for sandbox to become RUNNING (current: ${task.sandbox.status})`, } } + return { ready: true } + case 'UNINSTALL_SKILL': + if (!task.sandbox) { + return { ready: false, reason: 'Sandbox not found' } + } + if (task.sandbox.status !== 'RUNNING') { + return { + ready: false, + reason: `Waiting for sandbox to become RUNNING (current: ${task.sandbox.status})`, + } + } + if (!task.skillId) { + return { + ready: false, + reason: 'Missing skill identity for uninstall task', + terminalStatus: 'FAILED', + } + } + + const blockingInstallTask = await findRunningInstallSkillTaskCreatedBefore({ + projectId: task.projectId, + skillId: task.skillId, + createdBefore: task.createdAt, + }) + + if (blockingInstallTask) { + return { + ready: false, + reason: 'Waiting for older install task to settle before uninstalling', + } + } + return { ready: true } case 'INSTALL_SKILL': + if (!task.userSkillId) { + return { + ready: false, + reason: 'Superseded by global uninstall', + terminalStatus: 'CANCELLED', + } + } if (!task.sandbox) { return { ready: false, reason: 'Sandbox not found' } } @@ -205,7 +263,7 @@ async function evaluateTaskPrerequisites( return { ready: false, reason: 'Repository import failed, so skill installation cannot proceed', - terminalFailure: true, + terminalStatus: 'FAILED', } } diff --git a/lib/platform/control/commands/skill/index.ts b/lib/platform/control/commands/skill/index.ts index 12ebf2e..e455501 100644 --- a/lib/platform/control/commands/skill/index.ts +++ b/lib/platform/control/commands/skill/index.ts @@ -1 +1,2 @@ export { enableGlobalSkillCommand } from './enable-global-skill' +export { uninstallGlobalSkillCommand } from './uninstall-global-skill' diff --git a/lib/platform/control/commands/skill/uninstall-global-skill.ts b/lib/platform/control/commands/skill/uninstall-global-skill.ts new file mode 100644 index 0000000..8b7ae3e --- /dev/null +++ b/lib/platform/control/commands/skill/uninstall-global-skill.ts @@ -0,0 +1,178 @@ +import { prisma } from '@/lib/db' +import { triggerRunnableTasksForProject } from '@/lib/jobs/project-task' +import { logger as baseLogger } from '@/lib/logger' +import { CommandResult } from '@/lib/platform/control/types' +import { createUninstallSkillTask } from '@/lib/platform/persistence/project-task/create-uninstall-skill-task' +import { findSkillCatalogEntry } from '@/lib/skills/catalog' + +const logger = baseLogger.child({ + module: 'platform/control/commands/skill/uninstall-global-skill', +}) + +const STALE_INSTALL_CANCEL_REASON = 'Superseded by global uninstall' + +/** + * Removes a globally enabled skill and fans out uninstall work to projects that still need removal. + * + * Expected inputs: + * - A Fulling user ID and a stable skill ID from the local catalog. + * + * Expected outputs: + * - Deletes the `UserSkill` source of truth and creates `UNINSTALL_SKILL` tasks where needed. + * + * Out of scope: + * - Does not execute uninstall work directly. + * - Does not auto-start stopped sandboxes. + */ +export async function uninstallGlobalSkillCommand(input: { + userId: string + skillId: string +}): Promise> { + const skill = findSkillCatalogEntry(input.skillId) + + if (!skill) { + return { success: false, error: 'Skill not found' } + } + + try { + const runnableProjectIds: string[] = [] + + const removed = await prisma.$transaction( + async (tx) => { + const existingUserSkill = await tx.userSkill.findUnique({ + where: { + userId_skillId: { + userId: input.userId, + skillId: input.skillId, + }, + }, + }) + + if (!existingUserSkill) { + return false + } + + const projects = await tx.project.findMany({ + where: { + userId: input.userId, + }, + include: { + sandboxes: { + orderBy: { createdAt: 'asc' }, + select: { id: true, status: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + + for (const project of projects) { + const primarySandbox = project.sandboxes[0] + if (!primarySandbox) { + continue + } + + const latestInstalledOrRunningTask = await tx.projectTask.findFirst({ + where: { + projectId: project.id, + skillId: skill.skillId, + type: 'INSTALL_SKILL', + status: { + in: ['RUNNING', 'SUCCEEDED'], + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + const latestCoveringUninstallTask = await tx.projectTask.findFirst({ + where: { + projectId: project.id, + skillId: skill.skillId, + type: 'UNINSTALL_SKILL', + status: { + in: ['PENDING', 'WAITING_FOR_PREREQUISITES', 'RUNNING', 'SUCCEEDED'], + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + await tx.projectTask.updateMany({ + where: { + projectId: project.id, + skillId: skill.skillId, + type: 'INSTALL_SKILL', + status: { + in: ['PENDING', 'WAITING_FOR_PREREQUISITES'], + }, + }, + data: { + status: 'CANCELLED', + error: STALE_INSTALL_CANCEL_REASON, + lockedUntil: null, + startedAt: null, + finishedAt: new Date(), + }, + }) + + if ( + !latestInstalledOrRunningTask || + (latestCoveringUninstallTask && + latestCoveringUninstallTask.createdAt > latestInstalledOrRunningTask.createdAt) + ) { + continue + } + + await createUninstallSkillTask(tx, { + projectId: project.id, + sandboxId: primarySandbox.id, + userSkillId: existingUserSkill.id, + skillId: skill.skillId, + installCommand: existingUserSkill.installCommand, + uninstallCommand: skill.uninstallCommand, + }) + + if (primarySandbox.status === 'RUNNING') { + runnableProjectIds.push(project.id) + } + } + + await tx.userSkill.delete({ + where: { + id: existingUserSkill.id, + }, + }) + + return true + }, + { + timeout: 20000, + } + ) + + if (!removed) { + logger.info(`Global skill already absent: ${input.skillId} for user ${input.userId}`) + return { + success: true, + data: { + skillId: input.skillId, + }, + } + } + + await Promise.allSettled( + runnableProjectIds.map(async (projectId) => { + await triggerRunnableTasksForProject(projectId) + }) + ) + + logger.info(`Global skill uninstalled: ${input.skillId} for user ${input.userId}`) + return { + success: true, + data: { + skillId: input.skillId, + }, + } + } catch (error) { + logger.error(`Failed to uninstall global skill ${input.skillId} for user ${input.userId}: ${error}`) + return { success: false, error: 'Failed to uninstall skill' } + } +} diff --git a/lib/platform/persistence/project-task/create-uninstall-skill-task.ts b/lib/platform/persistence/project-task/create-uninstall-skill-task.ts new file mode 100644 index 0000000..7980ae5 --- /dev/null +++ b/lib/platform/persistence/project-task/create-uninstall-skill-task.ts @@ -0,0 +1,48 @@ +import type { Prisma } from '@prisma/client' + +import { createProjectTask } from '@/lib/repo/project-task' + +export type CreateUninstallSkillTaskInput = { + projectId: string + sandboxId: string + userSkillId: string + skillId: string + installCommand: string + uninstallCommand: string +} + +/** + * Persists uninstall work that converges a project away from a globally removed skill. + * + * Expected inputs: + * - A project and sandbox that already exist in persisted control-plane state. + * - A historical `UserSkill` identity plus install and uninstall command snapshots. + * + * Expected outputs: + * - Creates a ProjectTask record in WAITING_FOR_PREREQUISITES status. + * + * Out of scope: + * - Does not execute the uninstall command. + * - Does not deduplicate against existing tasks. + * - Does not decide whether uninstall work is required for the project. + */ +export async function createUninstallSkillTask( + tx: Prisma.TransactionClient, + input: CreateUninstallSkillTaskInput +) { + return createProjectTask(tx, { + projectId: input.projectId, + sandboxId: input.sandboxId, + skillId: input.skillId, + type: 'UNINSTALL_SKILL', + status: 'WAITING_FOR_PREREQUISITES', + triggerSource: 'POLICY_ROLLOUT', + payload: { + userSkillId: input.userSkillId, + skillId: input.skillId, + installCommand: input.installCommand, + uninstallCommand: input.uninstallCommand, + }, + maxAttempts: 3, + }) +} diff --git a/lib/repo/project-task.ts b/lib/repo/project-task.ts index dd72431..f3d6af9 100644 --- a/lib/repo/project-task.ts +++ b/lib/repo/project-task.ts @@ -152,6 +152,25 @@ export async function getLatestSuccessfulCloneTask(projectId: string) { }) } +export async function findRunningInstallSkillTaskCreatedBefore(input: { + projectId: string + skillId: string + createdBefore: Date +}) { + return prisma.projectTask.findFirst({ + where: { + projectId: input.projectId, + skillId: input.skillId, + type: 'INSTALL_SKILL', + status: 'RUNNING', + createdAt: { + lt: input.createdBefore, + }, + }, + orderBy: { createdAt: 'desc' }, + }) +} + export async function getRunnableTasksForProject( projectId: string, taskType?: ProjectTaskType diff --git a/lib/skills/catalog.ts b/lib/skills/catalog.ts index 32f2e9f..8107d17 100644 --- a/lib/skills/catalog.ts +++ b/lib/skills/catalog.ts @@ -4,6 +4,7 @@ export type SkillCatalogEntry = { description: string sourceUrl: string installCommand: string + uninstallCommand: string } const skillCatalog: SkillCatalogEntry[] = [ @@ -15,6 +16,7 @@ const skillCatalog: SkillCatalogEntry[] = [ sourceUrl: 'https://github.com/anthropics/skills', installCommand: 'npx -y skills add https://github.com/anthropics/skills --skill frontend-design -y', + uninstallCommand: 'npx -y skills remove frontend-design -y', }, ]