From 1baf833c3fdcc983f6244e3fd24313a33d86cd08 Mon Sep 17 00:00:00 2001 From: "Harold Kim (Paperclip)" Date: Thu, 9 Jul 2026 05:55:45 +0000 Subject: [PATCH] fix: align environment delete cancellation guard Rework the force-delete route guard to differentiate static blockers (`managed_local`, `instance_default`) from the runtime blocker (`active_runtime_use`). Static blockers always reject; the runtime blocker rejects unless `?force=true` is set. When `force=true`, attempt to cancel any active custom image setup session; if cancellation leaves the session in a non-terminal state (`starting`, `waiting_for_user`, `capturing`), return a 409 rather than deleting the row. Otherwise, release all active leases before proceeding to `removeIfDeletable`. Typed the active-status set as `const satisfies readonly EnvironmentCustomImageSetupSessionStatus[]` to ensure future status additions raise a compile error here. Co-Authored-By: Paperclip --- server/src/routes/environments.ts | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 6ea542fdcde..174851c48ec 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -12,6 +12,7 @@ import { redactEnvironmentCustomImageTemplate, startEnvironmentCustomImageSetupSessionSchema, type EnvironmentDeleteBlastRadius, + type EnvironmentCustomImageSetupSessionStatus, updateEnvironmentSchema, } from "@paperclipai/shared"; import { conflict, forbidden, unprocessable } from "../errors.js"; @@ -50,6 +51,16 @@ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; +const ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES = [ + "starting", + "waiting_for_user", + "capturing", +] as const satisfies readonly EnvironmentCustomImageSetupSessionStatus[]; + +function isActiveCustomImageSetupStatus(status: EnvironmentCustomImageSetupSessionStatus): boolean { + return (ACTIVE_CUSTOM_IMAGE_SETUP_STATUSES as readonly string[]).includes(status); +} + export function environmentRoutes( db: Db, options: { pluginWorkerManager?: PluginWorkerManager } = {}, @@ -813,10 +824,37 @@ export function environmentRoutes( res.status(404).json({ error: "Environment not found" }); return; } - if (!impact.canDelete) { + + const hasStaticBlocker = impact.deleteBlockedReasons.some( + (r) => r === "managed_local" || r === "instance_default", + ); + if (hasStaticBlocker) { rejectEnvironmentDelete({ actor, environment: existing, impact }); } + const hasRuntimeBlocker = impact.deleteBlockedReasons.includes("active_runtime_use"); + const forceDelete = req.query.force === "true"; + if (hasRuntimeBlocker && !forceDelete) { + rejectEnvironmentDelete({ actor, environment: existing, impact }); + } + + if (hasRuntimeBlocker && forceDelete) { + const overview = await customImages.getOverview({ environmentId: existing.id }); + if (overview.activeSession) { + const cancelled = await customImages.cancelSetupSession({ + sessionId: overview.activeSession.id, + reason: "environment_force_delete", + }); + if (isActiveCustomImageSetupStatus(cancelled.status)) { + throw conflict( + "Active custom image setup session could not be cancelled. Retry or wait for setup to complete before deleting.", + { deleteBlockedReasons: ["active_runtime_use"] }, + ); + } + } + await svc.releaseActiveLeasesForEnvironment(existing.id); + } + const removed = await svc.removeIfDeletable(existing.id); if (!removed) { const latestImpact = await svc.getDeleteBlastRadius(existing.id);