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 <noreply@paperclip.ing>
This commit is contained in:
Harold Kim (Paperclip)andPaperclip committed 2026-07-09 05:55:45 +00:00
1 parent d1ca30dd56
commit 1baf833c3f
1 file changed
+39 -1
+39 -1
View File
@@ -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);