From c62bb4b16b9f4aac0bbbf568335af5124ff485cb Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 23 Aug 2026 16:53:17 -0700 Subject: [PATCH] feat: environment delete with agent reassignment and consented sandbox destroy (#12053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Environments define where agent runs execute: local, SSH, or provider sandboxes > - Operators can create and edit environments, but the UI has no way to delete one > - The server already exposes `DELETE /environments/:id` and a delete-blast-radius preflight, but no UI consumes them, and a delete blocked by reusable sandbox leases gives the operator no path forward > - This pull request adds the delete flow to the environment configuration page: a preflight-driven modal that reassigns dependent agents, names the workspaces that hold blocking sandbox leases, and can destroy those sandboxes with explicit consent > - The benefit is that operators can retire stale environments from the UI without database surgery, and dependent agents move to a chosen replacement instead of silently falling back ## Linked Issues or Issue Description Refs #8554 Refs #11124 **Subsystem affected** Environments (server routes, environment runtime service, and the environment settings UI). **Problem or motivation** The environment configuration page has no delete control. The server delete endpoint exists, but nothing in the UI calls it. When reusable sandbox leases block a delete, the 409 error names no owner, so the operator cannot find the blocking workspace. Agents that use the environment as their default lose it silently through the FK `on delete set null`. **Proposed solution** Add a delete button with a confirmation modal on the environment edit page. The modal reads the delete-blast-radius preflight. It offers a dropdown to reassign dependent agents to another environment before the delete. It lists each workspace that holds a blocking reusable sandbox lease, with a link. When those leases are the only blocker, the confirm button destroys the sandboxes inline (`?destroyReusableSandboxLeases=true`) and then deletes. A failed teardown falls back to `pending_cleanup` for the sweep, so no sandbox is orphaned. ## What Changed - `ui/src/pages/CompanyEnvironments.tsx`: delete button on the edit page header, confirmation modal with agent reassignment select, lease-holder list, impact notes, and a consent-labeled destroy-and-delete action - `ui/src/api/environments.ts`: `deleteBlastRadius` and `remove` client methods; `remove` takes an optional `destroyReusableSandboxLeases` flag - `server/src/routes/environments.ts`: `DELETE /environments/:id` accepts `?destroyReusableSandboxLeases=true`; it destroys the environment's reusable sandbox leases first, but only when those leases are the sole delete blocker, then re-checks the blast radius before it deletes - `server/src/services/environment-runtime.ts`: new `destroyReusableSandboxLeasesForEnvironment` — destroys every reusable sandbox lease an environment still owns while the environment config (provider credentials) is still available - `server/src/services/environments.ts`: the delete blast radius now returns `reusableSandboxLeaseHolders` (lease id, workspace, issue) so clients can name what blocks a delete - `packages/shared/src/types/environment.ts`: `EnvironmentDeleteReusableLeaseHolder` type on the blast radius - Tests: route gating for the consent flag (destroy runs, mixed-blocker rejection, surviving-lease rejection), runtime destroy scoped to an environment, blast-radius holder join, and UI tests for the reassignment flow, holder links, and the consent button ## Verification - `npx vitest run server/src/__tests__/environment-routes.test.ts server/src/__tests__/environment-service.test.ts server/src/__tests__/environment-runtime.test.ts ui/src/pages/CompanyEnvironments.test.tsx` - Manual: open Settings → Environments → edit an environment. The trash icon opens the modal. With agents on the environment, pick a reassignment target and confirm; agents move and the environment deletes. With reusable sandbox leases, the modal names the holding workspaces and the confirm button reads "Destroy N sandboxes and delete". ## Risks - The consented path destroys provider sandboxes. It runs only when reusable leases are the sole blocker, so a delete that would still be rejected never destroys anything. A failed teardown routes to `pending_cleanup` and the delete stays blocked until the sweep resolves it. - Agent reassignment issues one PATCH per agent from the client. A mid-sequence failure leaves some agents reassigned; the reassignments are valid on their own and the UI refreshes to the actual state. - Hard blockers (managed local, instance default, pending cleanup) keep the existing 409 behavior and disable the confirm button. ## Model Used - Claude (Anthropic) — Fable 5, model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code CLI. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/shared/src/index.ts | 1 + packages/shared/src/types/environment.ts | 21 ++ packages/shared/src/types/index.ts | 1 + .../src/__tests__/environment-routes.test.ts | 112 ++++++ .../src/__tests__/environment-runtime.test.ts | 64 ++++ .../src/__tests__/environment-service.test.ts | 49 +++ server/src/routes/environments.ts | 67 +++- server/src/services/environment-runtime.ts | 129 ++++++- server/src/services/environments.ts | 22 +- ui/src/api/environments.ts | 12 + ui/src/pages/CompanyEnvironments.test.tsx | 295 +++++++++++++++ ui/src/pages/CompanyEnvironments.tsx | 343 +++++++++++++++++- 12 files changed, 1107 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dd55ba4d91..c36b450dba 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -669,6 +669,7 @@ export type { WriteSummarySlotResponse, Environment, EnvironmentDeleteBlastRadius, + EnvironmentDeleteReusableLeaseHolder, EnvironmentDeleteBlockedReason, EnvironmentLease, EnvironmentProbeResult, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 8384a63b1d..cfe028b128 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -89,6 +89,21 @@ export type EnvironmentDeleteBlockedReason = | "reusable_sandbox_lease" | "pending_sandbox_cleanup"; +/** + * One reusable sandbox lease that blocks an environment delete, with the + * workspace/issue that holds it. Closing the workspace (or removing the issue) + * lets Paperclip destroy the sandbox and release the lease. The workspace and + * issue references are nullable because the lease FKs use `on delete set null`. + */ +export interface EnvironmentDeleteReusableLeaseHolder { + leaseId: string; + executionWorkspaceId: string | null; + executionWorkspaceName: string | null; + issueId: string | null; + issueIdentifier: string | null; + issueTitle: string | null; +} + export interface EnvironmentDeleteBlastRadius { environmentId: string; canDelete: boolean; @@ -120,6 +135,12 @@ export interface EnvironmentDeleteBlastRadius { * release and destroy paths, so these leases block deletion. */ reusableSandboxLeaseCount: number; + /** + * One entry per lease behind `reusableSandboxLeaseCount`, so a client can + * name the blocking workspaces/issues instead of reporting a bare count. + * Several leases can share one workspace. + */ + reusableSandboxLeaseHolders: EnvironmentDeleteReusableLeaseHolder[]; } export interface EnvironmentLease { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2f63fd280d..5eb53a963c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -57,6 +57,7 @@ export { DECISION_TRAINING_RETENTION_POLICY } from "./decision-training.js"; export type { Environment, EnvironmentDeleteBlastRadius, + EnvironmentDeleteReusableLeaseHolder, EnvironmentDeleteBlockedReason, EnvironmentLease, EnvironmentProbeResult, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index b828a83bf2..eca4e23086 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -26,6 +26,9 @@ const mockProjectService = vi.hoisted(() => ({ clearExecutionWorkspaceEnvironmentSelection: vi.fn(), })); +const mockEnvironmentRuntimeService = vi.hoisted(() => ({ + destroyReusableSandboxLeasesForEnvironment: vi.fn(async () => ({ destroyed: 0, failed: 0, skippedLiveRun: 0 })), +})); const mockInstanceSettingsService = vi.hoisted(() => ({ listCompanyIds: vi.fn(), getGeneral: vi.fn(), @@ -105,6 +108,10 @@ vi.mock("../services/environments.js", () => ({ environmentService: () => mockEnvironmentService, })); +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: () => mockEnvironmentRuntimeService, +})); + vi.mock("../services/execution-workspaces.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, })); @@ -153,6 +160,14 @@ function createDeleteBlastRadius(overrides: Partial<{ activeCustomImageSetupSessionCount: number; pendingCleanupLeaseCount: number; reusableSandboxLeaseCount: number; + reusableSandboxLeaseHolders: Array<{ + leaseId: string; + executionWorkspaceId: string | null; + executionWorkspaceName: string | null; + issueId: string | null; + issueIdentifier: string | null; + issueTitle: string | null; + }>; }> = {}) { const staticReferences = { isManagedLocal: overrides.isManagedLocal ?? false, @@ -172,6 +187,16 @@ function createDeleteBlastRadius(overrides: Partial<{ }; const pendingCleanupLeaseCount = overrides.pendingCleanupLeaseCount ?? 0; const reusableSandboxLeaseCount = overrides.reusableSandboxLeaseCount ?? 0; + const reusableSandboxLeaseHolders = + overrides.reusableSandboxLeaseHolders + ?? Array.from({ length: reusableSandboxLeaseCount }, (_, index) => ({ + leaseId: `lease-${index + 1}`, + executionWorkspaceId: null, + executionWorkspaceName: null, + issueId: null, + issueIdentifier: null, + issueTitle: null, + })); const deleteBlockedReasons = [ ...(staticReferences.isManagedLocal ? ["managed_local" as const] : []), ...(staticReferences.isInstanceDefault ? ["instance_default" as const] : []), @@ -184,6 +209,7 @@ function createDeleteBlastRadius(overrides: Partial<{ deleteBlockedReasons, pendingCleanupLeaseCount, reusableSandboxLeaseCount, + reusableSandboxLeaseHolders, staticReferences, activeRuntimeUse, }; @@ -252,6 +278,8 @@ describe("environment routes", () => { mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); mockProjectService.getById.mockReset(); mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockReset(); + mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue({ destroyed: 0, failed: 0, skippedLiveRun: 0 }); mockInstanceSettingsService.listCompanyIds.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); mockInstanceSettingsService.getGeneral.mockResolvedValue({ executionMode: "any" }); @@ -1091,6 +1119,7 @@ describe("environment routes", () => { deleteBlockedReasons: [], pendingCleanupLeaseCount: 0, reusableSandboxLeaseCount: 0, + reusableSandboxLeaseHolders: [], staticReferences: { isManagedLocal: false, isInstanceDefault: false, @@ -1601,6 +1630,89 @@ describe("environment routes", () => { ); expect(res.body.details).toEqual({ deleteBlockedReasons: ["reusable_sandbox_lease"] }); expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); + expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment).not.toHaveBeenCalled(); + }); + + it("destroys reusable sandbox leases and deletes with explicit consent", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius + .mockResolvedValueOnce(createDeleteBlastRadius({ reusableSandboxLeaseCount: 2 })) + .mockResolvedValueOnce(createDeleteBlastRadius()); + mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue({ + destroyed: 2, + failed: 0, + skippedLiveRun: 0, + }); + mockEnvironmentService.removeIfDeletable.mockResolvedValue(environment); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); + + expect(res.status).toBe(200); + expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment) + .toHaveBeenCalledExactlyOnceWith({ + environmentId: "env-1", + failureReason: "environment_deleted", + }); + expect(mockEnvironmentService.removeIfDeletable).toHaveBeenCalledWith("env-1"); + expect(res.body.destroyedReusableSandboxLeaseCount).toBe(2); + }); + + it("keeps rejecting a consented delete when a non-lease blocker remains", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({ + isInstanceDefault: true, + reusableSandboxLeaseCount: 1, + })); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); + + // Destroying provider sandboxes and then rejecting on the other gate would + // be an irreversible action with nothing gained, so the destroy must not run. + expect(res.status).toBe(409); + expect(mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment).not.toHaveBeenCalled(); + expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); + }); + + it("keeps rejecting when leases survive the consented destroy", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.getDeleteBlastRadius + .mockResolvedValueOnce(createDeleteBlastRadius({ reusableSandboxLeaseCount: 1 })) + .mockResolvedValueOnce(createDeleteBlastRadius({ pendingCleanupLeaseCount: 1 })); + mockEnvironmentRuntimeService.destroyReusableSandboxLeasesForEnvironment.mockResolvedValue({ + destroyed: 1, + failed: 1, + skippedLiveRun: 0, + }); + const app = createApp({ + type: "board", + userId: "admin-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/env-1?destroyReusableSandboxLeases=true"); + + expect(res.status).toBe(409); + // The rejection names what the consented destroy already did: provider + // destruction is not transactional with the delete guard. + expect(res.body.details).toEqual({ + deleteBlockedReasons: ["pending_sandbox_cleanup"], + destroyedReusableSandboxLeaseCount: 1, + }); + expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled(); }); it("rejects a driver or provider config change while a sandbox cleanup is pending", async () => { diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index ed424100f6..e5873137af 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -5639,6 +5639,70 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }); }); + it("destroys reusable plugin-backed sandbox leases scoped to an environment", async () => { + const { pluginId, runId, reusableLease } = await seedReusablePluginSandboxLease(); + // The holding run is finished, so the reservation is stale and destroyable. + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId)); + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string) => { + if (method === "environmentDestroyLease") { + return undefined; + } + throw new Error(`Unexpected plugin method: ${method}`); + }), + getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const result = await runtimeWithPlugin.destroyReusableSandboxLeasesForEnvironment({ + environmentId: reusableLease.environmentId!, + failureReason: "environment_deleted", + }); + + expect(result).toEqual({ destroyed: 1, failed: 0, skippedLiveRun: 0 }); + expect(workerManager.call).toHaveBeenCalledWith( + pluginId, + "environmentDestroyLease", + expect.objectContaining({ + driverKey: "fake-plugin", + providerLeaseId: "reusable-plugin-lease", + }), + 31234, + ); + await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({ + status: "expired", + failureReason: "environment_deleted", + cleanupStatus: "success", + }); + }); + + it("keeps a reusable lease held by an in-flight run out of the environment-scoped destroy", async () => { + const { pluginId, reusableLease } = await seedReusablePluginSandboxLease(); + // seedEnvironment leaves the holding run in `running` status. + + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async () => undefined), + getWorker: vi.fn(() => ({ supportedMethods: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"] })), + } as unknown as PluginWorkerManager; + const runtimeWithPlugin = environmentRuntimeService(db, { pluginWorkerManager: workerManager }); + + const result = await runtimeWithPlugin.destroyReusableSandboxLeasesForEnvironment({ + environmentId: reusableLease.environmentId!, + failureReason: "environment_deleted", + }); + + expect(result).toEqual({ destroyed: 0, failed: 0, skippedLiveRun: 1 }); + expect(workerManager.call).not.toHaveBeenCalled(); + // The lease keeps its reusable status, so the delete guard still blocks. + await expect(environmentService(db).getLeaseById(reusableLease.id)).resolves.toMatchObject({ + status: "active", + leasePolicy: "reuse_by_environment", + }); + }); + it("sweeps reusable cleanup with the configuration recorded on the lease", async () => { const { pluginId, environment, reusableLease } = await seedReusablePluginSandboxLease(); const environmentsSvc = environmentService(db); diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index 1f77984a71..f26bebfdad 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -399,6 +399,7 @@ describeEmbeddedPostgres("environmentService leases", () => { deleteBlockedReasons: ["instance_default"], pendingCleanupLeaseCount: 0, reusableSandboxLeaseCount: 0, + reusableSandboxLeaseHolders: [], staticReferences: { isManagedLocal: false, isInstanceDefault: true, @@ -559,9 +560,47 @@ describeEmbeddedPostgres("environmentService leases", () => { createdAt: now, updatedAt: now, }); + const projectId = randomUUID(); + const issueId = randomUUID(); + const workspaceId = randomUUID(); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Project", + status: "in_progress", + createdAt: now, + updatedAt: now, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + identifier: "ACME-7", + title: "Reusable lease holder", + status: "todo", + priority: "medium", + createdAt: now, + updatedAt: now, + }); + await db.insert(executionWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "ACME-7-reusable-lease-holder", + status: "active", + providerType: "git_worktree", + metadata: {}, + createdAt: now, + updatedAt: now, + }); const lease = await svc.acquireLease({ companyId, environmentId, + executionWorkspaceId: workspaceId, + issueId, leasePolicy: "reuse_by_environment", provider: "fake", providerLeaseId: "sandbox-reusable-1", @@ -577,6 +616,16 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(activeImpact?.canDelete).toBe(false); expect(activeImpact?.deleteBlockedReasons).toContain("reusable_sandbox_lease"); expect(activeImpact?.reusableSandboxLeaseCount).toBe(1); + expect(activeImpact?.reusableSandboxLeaseHolders).toEqual([ + { + leaseId: lease.id, + executionWorkspaceId: workspaceId, + executionWorkspaceName: "ACME-7-reusable-lease-holder", + issueId, + issueIdentifier: "ACME-7", + issueTitle: "Reusable lease holder", + }, + ]); expect(await svc.removeIfDeletable(environmentId)).toBeNull(); // A released reusable lease still owns a provider sandbox that may be diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 053d11f115..2b0916c49c 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -57,6 +57,7 @@ import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; +import { environmentRuntimeService } from "../services/environment-runtime.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; function isPlainRecord(value: unknown): value is Record { @@ -329,6 +330,9 @@ export function environmentRoutes( ) { const router = Router(); const svc = environmentService(db); + const environmentRuntime = environmentRuntimeService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); const customImages = environmentCustomImageService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -597,6 +601,10 @@ export function environmentRoutes( actor: ReturnType; environment: { id: string; driver: string }; impact: EnvironmentDeleteBlastRadius; + // Sandboxes a consented delete destroyed before this rejection. Provider + // destruction is not transactional with the delete guard, so a rejection + // after a partial destroy must say what already happened. + destroyedReusableSandboxLeaseCount?: number; }): never { const message = environmentDeleteBlockMessage(input.impact) @@ -606,6 +614,7 @@ export function environmentRoutes( environmentId: input.environment.id, environmentDriver: input.environment.driver, deleteBlockedReasons: input.impact.deleteBlockedReasons, + destroyedReusableSandboxLeaseCount: input.destroyedReusableSandboxLeaseCount ?? 0, actorType: input.actor.actorType, actorId: input.actor.actorId, agentId: input.actor.agentId, @@ -613,7 +622,12 @@ export function environmentRoutes( }, "environment delete rejected by guard", ); - throw conflict(message, { deleteBlockedReasons: input.impact.deleteBlockedReasons }); + throw conflict(message, { + deleteBlockedReasons: input.impact.deleteBlockedReasons, + ...(input.destroyedReusableSandboxLeaseCount + ? { destroyedReusableSandboxLeaseCount: input.destroyedReusableSandboxLeaseCount } + : {}), + }); } function setupSessionActivityDetails(session: { @@ -1252,13 +1266,52 @@ export function environmentRoutes( } await assertPlatformProvisionedEnvironmentWritable(existing); const actor = getActorInfo(req); - const impact = await svc.getDeleteBlastRadius(existing.id); + let impact = await svc.getDeleteBlastRadius(existing.id); if (!impact) { res.status(404).json({ error: "Environment not found" }); return; } + // With explicit consent, destroy the environment's reusable sandbox leases + // so the delete can proceed — but only while those leases are the sole + // blocker. Destroying provider resources and then rejecting on another + // gate would be an irreversible action with nothing gained. The destroy + // must run before the delete: the driver needs the environment config to + // reach the provider. A lease whose teardown fails lands in + // `pending_cleanup`, and the re-fetched blast radius rejects below until + // the cleanup sweep resolves it — no sandbox is ever orphaned silently. + // A lease held by an in-flight run is skipped, keeps blocking, and the + // re-check rejects the delete without touching that run's sandbox. + // + // The gate above and the destroy are not one atomic step: provider calls + // cannot join a database transaction, so a blocker that lands in the + // window between them (a new instance default, a fresh pending cleanup) + // rejects the delete only after some sandboxes are already gone. Those + // sandboxes belonged to the environment the operator consented to + // destroy; the rejection reports the count so nothing is silent. + let destroyedReusableSandboxLeaseCount = 0; + if ( + req.query.destroyReusableSandboxLeases === "true" + && impact.reusableSandboxLeaseCount > 0 + && impact.deleteBlockedReasons.every((reason) => reason === "reusable_sandbox_lease") + ) { + const destroyResult = await environmentRuntime.destroyReusableSandboxLeasesForEnvironment({ + environmentId: existing.id, + failureReason: "environment_deleted", + }); + destroyedReusableSandboxLeaseCount = destroyResult.destroyed; + impact = await svc.getDeleteBlastRadius(existing.id); + if (!impact) { + res.status(404).json({ error: "Environment not found" }); + return; + } + } if (!impact.canDelete) { - rejectEnvironmentDelete({ actor, environment: existing, impact }); + rejectEnvironmentDelete({ + actor, + environment: existing, + impact, + destroyedReusableSandboxLeaseCount, + }); } const removed = await svc.removeIfDeletable(existing.id); @@ -1301,9 +1354,15 @@ export function environmentRoutes( name: removed.name, driver: removed.driver, status: removed.status, + ...(destroyedReusableSandboxLeaseCount > 0 + ? { destroyedReusableSandboxLeaseCount } + : {}), }, }); - res.json(presentEnvironmentForRead(req, removed)); + res.json({ + ...presentEnvironmentForRead(req, removed), + destroyedReusableSandboxLeaseCount, + }); }); router.post("/environments/:id/probe", async (req, res) => { diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 9187a59aa8..6115ecd0d2 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { companySecrets, companySecretVersions, environmentLeases } from "@paperclipai/db"; +import { companySecrets, companySecretVersions, environmentLeases, heartbeatRuns } from "@paperclipai/db"; import type { Environment, EnvironmentLease, @@ -3405,6 +3405,131 @@ export function environmentRuntimeService( return destroyed; }, + /** + * Destroy every reusable sandbox lease still owned by one environment, so a + * consented environment delete can proceed. This must run while the + * environment row still exists: the driver resolves provider credentials + * from the environment config, and after the delete the normal destroy path + * has no context left. A per-lease failure is contained — the driver routes + * a failed teardown to `pending_cleanup` for the sweep, and an unexpected + * throw leaves the lease in place — so the caller re-checks the blast + * radius instead of trusting these counts for the delete decision. + */ + async destroyReusableSandboxLeasesForEnvironment(input: { + environmentId: string; + failureReason?: string; + }): Promise<{ destroyed: number; failed: number; skippedLiveRun: number }> { + const environment = await environmentsSvc.getById(input.environmentId); + if (!environment) return { destroyed: 0, failed: 0, skippedLiveRun: 0 }; + const leaseRows = await db + .select() + .from(environmentLeases) + .where( + and( + eq(environmentLeases.environmentId, input.environmentId), + eq(environmentLeases.leasePolicy, "reuse_by_environment"), + inArray(environmentLeases.status, ["active", "released", "retained"]), + ), + ); + + // A lease whose holding run is still in flight keeps its sandbox: the + // consented delete must not tear a live run's environment out from under + // it. The skipped lease keeps blocking the delete, so the caller's + // blast-radius re-check rejects and the operator retries after the run + // finishes. A lease pointing at a finished run — or at no run — is a + // stale reservation and destroys normally. + const holdingRunIds = leaseRows + .map((row) => row.heartbeatRunId) + .filter((runId): runId is string => Boolean(runId)); + const liveRunIds = new Set(); + if (holdingRunIds.length > 0) { + const liveRuns = await db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.id, holdingRunIds), + inArray(heartbeatRuns.status, ["queued", "scheduled_retry", "running"]), + ), + ); + for (const run of liveRuns) liveRunIds.add(run.id); + } + + let destroyed = 0; + let failed = 0; + let skippedLiveRun = 0; + const failureReason = input.failureReason ?? "environment_delete_requested"; + const now = new Date(); + for (const leaseRow of leaseRows) { + if (leaseRow.heartbeatRunId && liveRunIds.has(leaseRow.heartbeatRunId)) { + skippedLiveRun += 1; + continue; + } + // Claim the row BEFORE the provider call, mirroring the inline-teardown + // invariant used elsewhere in this file: no provider destroy without a + // durable `pending_cleanup` reference already on disk. The claim is one + // conditional UPDATE, so it is the fence against a racing resume: a + // resume that re-activates the lease first makes the status predicate + // (or the run-liveness predicate) fail and the claim loses — the live + // run keeps its sandbox. A claim that wins parks the lease where the + // cleanup sweep owns it, so a crash or thrown destroy after this point + // is recovered by the sweep's idempotent teardown, and a double write + // failure cannot strand the lease in a reusable status. + const claimedRow = await db + .update(environmentLeases) + .set({ + status: "pending_cleanup", + failureReason, + cleanupStatus: "failed", + releasedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(environmentLeases.id, leaseRow.id), + inArray(environmentLeases.status, ["active", "released", "retained"]), + sql`NOT EXISTS ( + SELECT 1 FROM ${heartbeatRuns} + WHERE ${heartbeatRuns.id} = ${environmentLeases.heartbeatRunId} + AND ${heartbeatRuns.status} IN ('queued', 'scheduled_retry', 'running') + )`, + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!claimedRow) { + // Lost to a racing resume or a concurrent terminal transition — the + // lease is no longer ours to destroy. + skippedLiveRun += 1; + continue; + } + const leaseSnapshot = toEnvironmentLeaseSnapshot(claimedRow); + try { + const driver = getDriver(getLeaseDriverKey(leaseSnapshot, environment)); + if (!driver?.destroyRunLease) { + // No driver available: the claim already parked the lease for the + // sweep, which retries once the driver's plugin is back. + failed += 1; + continue; + } + const lease = await driver.destroyRunLease({ + environment, + lease: leaseSnapshot, + failureReason, + }); + if (lease && lease.status !== "pending_cleanup") destroyed += 1; + else failed += 1; + } catch { + // The claim above already parked the lease in `pending_cleanup`, so + // the sweep owns the retry; its teardown is idempotent, so a destroy + // that reached the provider before the throw resolves as success. + failed += 1; + } + } + return { destroyed, failed, skippedLiveRun }; + }, + async resumeRunLease(input: EnvironmentDriverLeaseInput): Promise { const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment)); if (!driver.resumeRunLease) { diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 11e7a825a7..b491bc83af 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1230,8 +1230,17 @@ export function environmentService(db: Db) { ), ), db - .select({ count: sql`count(*)::int` }) + .select({ + leaseId: environmentLeases.id, + executionWorkspaceId: environmentLeases.executionWorkspaceId, + executionWorkspaceName: executionWorkspaces.name, + issueId: environmentLeases.issueId, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + }) .from(environmentLeases) + .leftJoin(executionWorkspaces, eq(environmentLeases.executionWorkspaceId, executionWorkspaces.id)) + .leftJoin(issues, eq(environmentLeases.issueId, issues.id)) .where( and( eq(environmentLeases.environmentId, id), @@ -1253,7 +1262,15 @@ export function environmentService(db: Db) { const isManagedLocal = environment.driver === "local"; const isInstanceDefault = countFromRows(instanceDefaultRows) > 0; const pendingCleanupLeaseCount = countFromRows(pendingCleanupLeaseRows); - const reusableSandboxLeaseCount = countFromRows(reusableSandboxLeaseRows); + const reusableSandboxLeaseHolders = reusableSandboxLeaseRows.map((row) => ({ + leaseId: row.leaseId, + executionWorkspaceId: row.executionWorkspaceId, + executionWorkspaceName: row.executionWorkspaceName, + issueId: row.issueId, + issueIdentifier: row.issueIdentifier, + issueTitle: row.issueTitle, + })); + const reusableSandboxLeaseCount = reusableSandboxLeaseHolders.length; const deleteBlockedReasons: EnvironmentDeleteBlockedReason[] = []; if (isManagedLocal) deleteBlockedReasons.push("managed_local"); if (isInstanceDefault) deleteBlockedReasons.push("instance_default"); @@ -1273,6 +1290,7 @@ export function environmentService(db: Db) { deleteBlockedReasons, pendingCleanupLeaseCount, reusableSandboxLeaseCount, + reusableSandboxLeaseHolders, staticReferences: { isManagedLocal, isInstanceDefault, diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index b823ccd299..3e9c8d4da5 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -2,6 +2,7 @@ import type { CancelEnvironmentCustomImageSetupSession, Environment, EnvironmentCapabilities, + EnvironmentDeleteBlastRadius, EnvironmentLease, EnvironmentProbeResult, EnvironmentCustomImageSetupSession, @@ -112,6 +113,17 @@ export const environmentsApi = { lease: (leaseId: string) => api.get(`/environment-leases/${leaseId}`), secretRefs: (environmentId: string) => api.get<{ refs: EnvironmentSecretRefDescriptor[] }>(`/environments/${environmentId}/secret-refs`), + deleteBlastRadius: (environmentId: string) => + api.get(`/environments/${environmentId}/delete-blast-radius`), + // The flag consents to destroying the environment's reusable sandbox leases + // inline so the delete can proceed; without it the server rejects with 409 + // while such leases exist. + remove: (environmentId: string, options: { destroyReusableSandboxLeases?: boolean } = {}) => + api.delete( + options.destroyReusableSandboxLeases + ? `/environments/${environmentId}?destroyReusableSandboxLeases=true` + : `/environments/${environmentId}`, + ), create: (companyId: string, body: { name: string; description?: string | null; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index ee708c9783..b1d5aaddca 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -131,6 +131,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), remove: vi.fn(), + deleteBlastRadius: vi.fn(), setDefault: vi.fn(), customImageTemplate: vi.fn(), startCustomImageSetupSession: vi.fn(), @@ -149,6 +150,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ const mockSecretsApi = vi.hoisted(() => ({ list: vi.fn(), })); +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), + update: vi.fn(), +})); vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ @@ -179,6 +184,10 @@ vi.mock("@/api/secrets", () => ({ secretsApi: mockSecretsApi, })); +vi.mock("@/api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; // Minimal browser APIs for jsdom. @@ -284,6 +293,12 @@ function setInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); } +function setSelectValue(select: HTMLSelectElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set; + setter?.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); +} + const ENVIRONMENTS_PATH = "/company/settings/instance/environments"; function getEnvironmentFormPage(): HTMLElement | null { @@ -373,6 +388,41 @@ function createTemplate(overrides: Record = {}) { }; } +function createDeleteBlastRadius(overrides: { + canDelete?: boolean; + deleteBlockedReasons?: string[]; + staticReferences?: Record; + activeRuntimeUse?: Record; + pendingCleanupLeaseCount?: number; + reusableSandboxLeaseCount?: number; + reusableSandboxLeaseHolders?: Array>; +} = {}) { + return { + environmentId: "env-1", + canDelete: overrides.canDelete ?? true, + deleteBlockedReasons: overrides.deleteBlockedReasons ?? [], + reusableSandboxLeaseHolders: overrides.reusableSandboxLeaseHolders ?? [], + staticReferences: { + isManagedLocal: false, + isInstanceDefault: false, + agentDefaultCount: 0, + executionWorkspaceSelectionCount: 0, + issueSelectionCount: 0, + projectSelectionCount: 0, + secretBindingCount: 0, + ...overrides.staticReferences, + }, + activeRuntimeUse: { + activeLeaseCount: 0, + activeCustomImageSetupSessionCount: 0, + hasActiveRuntimeUse: false, + ...overrides.activeRuntimeUse, + }, + pendingCleanupLeaseCount: overrides.pendingCleanupLeaseCount ?? 0, + reusableSandboxLeaseCount: overrides.reusableSandboxLeaseCount ?? 0, + }; +} + function supportedDaytonaCapabilities() { return { adapters: [], @@ -474,6 +524,16 @@ describe("CompanyEnvironments — test provider button", () => { description: null, config: { provider: "e2b" }, })); + mockEnvironmentsApi.deleteBlastRadius.mockResolvedValue(createDeleteBlastRadius()); + mockEnvironmentsApi.remove.mockImplementation(async (environmentId: string) => ({ + id: environmentId, + name: environmentId === "env-1" ? "Alpha" : "Beta", + driver: "sandbox", + description: null, + config: { provider: "e2b" }, + })); + mockAgentsApi.list.mockResolvedValue([]); + mockAgentsApi.update.mockResolvedValue({}); // Each probe stays pending until its resolver is called, so the testing // state remains observable and can be settled per environment. mockEnvironmentsApi.probe.mockImplementation( @@ -1632,4 +1692,239 @@ describe("CompanyEnvironments — test provider button", () => { // Saved non-local environments remain selectable defaults. expect(options.some((option) => option.textContent?.includes("Alpha"))).toBe(true); }); + + it("reassigns this company's active agents to the chosen environment before deleting", async () => { + mockAgentsApi.list.mockResolvedValue([ + { id: "agent-1", name: "Iris", status: "active", defaultEnvironmentId: "env-1" }, + { id: "agent-2", name: "Miles", status: "active", defaultEnvironmentId: "env-2" }, + { id: "agent-3", name: "Retired", status: "terminated", defaultEnvironmentId: "env-1" }, + ]); + mockEnvironmentsApi.deleteBlastRadius.mockResolvedValue( + createDeleteBlastRadius({ staticReferences: { agentDefaultCount: 2 } }), + ); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient, `${ENVIRONMENTS_PATH}/env-1/edit`)); + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + + const deleteButton = document.body.querySelector("[data-testid='environment-delete-button']"); + expect(deleteButton).not.toBeNull(); + await act(async () => click(deleteButton)); + + await waitForAssertion(() => { + expect( + document.body.querySelector("[data-testid='environment-delete-dialog']")?.textContent, + ).toContain("1 agent uses this environment as their default"); + }); + const dialog = document.body.querySelector("[data-testid='environment-delete-dialog']")!; + // Only this company's active agents are reassignable; the terminated one is + // left to the FK fallback and surfaces through the impact note instead. + expect(dialog.textContent).toContain("Iris"); + expect(dialog.textContent).not.toContain("Retired"); + expect(dialog.textContent).toContain("Other references to this environment"); + + const reassignSelect = dialog.querySelector( + "[data-testid='environment-delete-reassign-select']", + )!; + const optionLabels = Array.from(reassignSelect.querySelectorAll("option")).map((option) => + option.textContent?.trim(), + ); + expect(optionLabels).toEqual(["Default: Local", "Beta · sandbox"]); + await act(async () => setSelectValue(reassignSelect, "env-2")); + + await act(async () => + click(document.body.querySelector("[data-testid='environment-delete-confirm']")), + ); + await flushReact(); + + expect(mockAgentsApi.update).toHaveBeenCalledExactlyOnceWith( + "agent-1", + { defaultEnvironmentId: "env-2" }, + "company-1", + ); + expect(mockEnvironmentsApi.remove).toHaveBeenCalledExactlyOnceWith("env-1"); + expect(mockAgentsApi.update.mock.invocationCallOrder[0]).toBeLessThan( + mockEnvironmentsApi.remove.mock.invocationCallOrder[0], + ); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()).toBeNull(); + }); + }); + + it("deletes without any reassignment when no agents use the environment", async () => { + mockAgentsApi.list.mockResolvedValue([ + { id: "agent-2", name: "Miles", status: "active", defaultEnvironmentId: "env-2" }, + ]); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient, `${ENVIRONMENTS_PATH}/env-1/edit`)); + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + + await act(async () => + click(document.body.querySelector("[data-testid='environment-delete-button']")), + ); + await waitForAssertion(() => { + expect( + document.body.querySelector("[data-testid='environment-delete-dialog']")?.textContent, + ).toContain("permanently deleted"); + }); + expect( + document.body.querySelector("[data-testid='environment-delete-reassign-select']"), + ).toBeNull(); + + await act(async () => + click(document.body.querySelector("[data-testid='environment-delete-confirm']")), + ); + await flushReact(); + + expect(mockAgentsApi.update).not.toHaveBeenCalled(); + expect(mockEnvironmentsApi.remove).toHaveBeenCalledExactlyOnceWith("env-1"); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()).toBeNull(); + }); + }); + + it("disables the delete confirmation while the server would block the delete", async () => { + mockEnvironmentsApi.deleteBlastRadius.mockResolvedValue( + createDeleteBlastRadius({ + canDelete: false, + deleteBlockedReasons: ["instance_default"], + staticReferences: { isInstanceDefault: true }, + }), + ); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient, `${ENVIRONMENTS_PATH}/env-1/edit`)); + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + + await act(async () => + click(document.body.querySelector("[data-testid='environment-delete-button']")), + ); + await waitForAssertion(() => { + expect( + document.body.querySelector("[data-testid='environment-delete-dialog']")?.textContent, + ).toContain("Cannot delete the current instance default environment"); + }); + + const confirm = document.body.querySelector( + "[data-testid='environment-delete-confirm']", + )!; + expect(confirm.disabled).toBe(true); + expect( + document.body.querySelector("[data-testid='environment-delete-reassign-select']"), + ).toBeNull(); + expect(mockEnvironmentsApi.remove).not.toHaveBeenCalled(); + }); + + it("links the workspaces holding blocking sandbox leases in the delete dialog", async () => { + mockEnvironmentsApi.deleteBlastRadius.mockResolvedValue( + createDeleteBlastRadius({ + canDelete: false, + deleteBlockedReasons: ["reusable_sandbox_lease"], + reusableSandboxLeaseCount: 3, + reusableSandboxLeaseHolders: [ + { + leaseId: "lease-1", + executionWorkspaceId: "ws-1", + executionWorkspaceName: "ACME-7-fix-the-thing", + issueId: "issue-1", + issueIdentifier: "ACME-7", + issueTitle: "Fix the thing", + }, + { + leaseId: "lease-2", + executionWorkspaceId: "ws-1", + executionWorkspaceName: "ACME-7-fix-the-thing", + issueId: "issue-2", + issueIdentifier: "ACME-9", + issueTitle: "Follow-up", + }, + { + leaseId: "lease-3", + executionWorkspaceId: null, + executionWorkspaceName: null, + issueId: null, + issueIdentifier: null, + issueTitle: null, + }, + ], + }), + ); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient, `${ENVIRONMENTS_PATH}/env-1/edit`)); + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Edit environment"); + }); + + await act(async () => + click(document.body.querySelector("[data-testid='environment-delete-button']")), + ); + await waitForAssertion(() => { + expect( + document.body.querySelector("[data-testid='environment-delete-lease-holders']"), + ).not.toBeNull(); + }); + + const holders = document.body.querySelector( + "[data-testid='environment-delete-lease-holders']", + )!; + const workspaceLink = holders.querySelector("a[href='/execution-workspaces/ws-1']"); + expect(workspaceLink?.textContent).toBe("ACME-7-fix-the-thing"); + // The two leases on ws-1 collapse into one row naming both issues. + expect(holders.querySelectorAll("li")).toHaveLength(2); + expect(holders.textContent).toContain("2 sandbox leases"); + expect(holders.textContent).toContain("ACME-7, ACME-9"); + expect(holders.textContent).toContain("Workspace no longer on record"); + + // Leases as the only blocker are consentable: the confirm stays enabled + // and destroys the sandboxes as part of the delete. + const confirm = document.body.querySelector( + "[data-testid='environment-delete-confirm']", + )!; + expect(confirm.disabled).toBe(false); + expect(confirm.textContent?.trim()).toBe("Destroy 3 sandboxes and delete"); + + await act(async () => click(confirm)); + await flushReact(); + + expect(mockEnvironmentsApi.remove).toHaveBeenCalledExactlyOnceWith("env-1", { + destroyReusableSandboxLeases: true, + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()).toBeNull(); + }); + }); + + it("does not offer delete on the add-environment page", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient, `${ENVIRONMENTS_PATH}/new`)); + }); + await waitForAssertion(() => { + expect(getEnvironmentFormPage()?.textContent).toContain("Add environment"); + }); + + expect(document.body.querySelector("[data-testid='environment-delete-button']")).toBeNull(); + }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 3a3667d210..c4ffc09646 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -13,6 +13,7 @@ import "@xterm/xterm/css/xterm.css"; import { type EnvBinding, type Environment, + type EnvironmentDeleteBlastRadius, type EnvironmentProviderCapability, type EnvironmentProbeResult, type EnvironmentCustomImageSetupSession, @@ -26,9 +27,20 @@ import { type EnvironmentCustomImageSetupSessionResult, type EnvironmentUpdateResult, } from "@/api/environments"; +import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { EnvironmentVariablesEditor, @@ -81,6 +93,25 @@ function environmentEditPath(environmentId: string) { return `${ENVIRONMENTS_PATH}/${encodeURIComponent(environmentId)}/edit`; } +// Keep in sync with environmentDeleteBlockMessage in server/src/routes/environments.ts — +// the server enforces these gates with a 409; this copy lets the modal explain +// the block before the user hits it. +function environmentDeleteBlockMessage(impact: EnvironmentDeleteBlastRadius): string | null { + if (impact.staticReferences.isManagedLocal) { + return "Cannot delete the managed local environment."; + } + if (impact.staticReferences.isInstanceDefault) { + return "Cannot delete the current instance default environment. Set a new default environment before deleting this one."; + } + if (impact.pendingCleanupLeaseCount > 0) { + return "Cannot delete this environment while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry."; + } + if (impact.reusableSandboxLeaseCount > 0) { + return "Cannot delete this environment while it has a reusable sandbox lease. Remove the associated execution workspace or issue so Paperclip can destroy the sandbox, then retry."; + } + return null; +} + function buildEnvironmentPayload(form: EnvironmentFormState) { return { name: form.name.trim(), @@ -1277,6 +1308,10 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) const [environmentVariablesDirty, setEnvironmentVariablesDirty] = useState(false); const [probeResults, setProbeResults] = useState>({}); const [testingEnvironmentId, setTestingEnvironmentId] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + // "" means "inherit the instance default", mirroring the agent config form's + // environment override select. + const [reassignEnvironmentTargetId, setReassignEnvironmentTargetId] = useState(""); const environmentHasUnsavedChanges = isEnvironmentFormPage && (environmentVariablesDirty || @@ -1314,6 +1349,22 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) enabled: Boolean(selectedCompanyId) && environmentsEnabled, }); const savedEnvironments = environments ?? []; + // Delete preflight: the blast radius names what still references the + // environment, and the agent list identifies which of this company's agents + // need reassignment. Both only load while the delete dialog is open. + const deleteBlastRadiusQuery = useQuery({ + queryKey: editingEnvironmentId + ? ["environment-delete-blast-radius", editingEnvironmentId] + : ["environment-delete-blast-radius", "none"], + queryFn: () => environmentsApi.deleteBlastRadius(editingEnvironmentId!), + enabled: deleteDialogOpen && Boolean(editingEnvironmentId), + retry: false, + }); + const companyAgentsQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"], + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: deleteDialogOpen && Boolean(selectedCompanyId), + }); // Descriptors for the edited environment's secret refs. Environments are // instance-scoped while secrets are company-scoped, so a ref may point at // a secret this company's picker cannot list; these hints let the picker @@ -1479,6 +1530,68 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) }, }); + const deleteEnvironmentMutation = useMutation({ + mutationFn: async (input: { + environment: Environment; + reassignAgentIds: string[]; + reassignTargetId: string | null; + destroyReusableLeases: boolean; + }) => { + // Reassign before deleting: the FK would null the references anyway, but + // an explicit PATCH records the change in each agent's config history and + // honors the operator's chosen target instead of the implicit fallback. + for (const agentId of input.reassignAgentIds) { + await agentsApi.update( + agentId, + { defaultEnvironmentId: input.reassignTargetId }, + selectedCompanyId ?? undefined, + ); + } + return input.destroyReusableLeases + ? await environmentsApi.remove(input.environment.id, { destroyReusableSandboxLeases: true }) + : await environmentsApi.remove(input.environment.id); + }, + onSuccess: async (environment, input) => { + if (selectedCompanyId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.environments.list(selectedCompanyId) }); + if (input.reassignAgentIds.length > 0) { + await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); + } + } + queryClient.removeQueries({ queryKey: ["environment-delete-blast-radius", environment.id] }); + setDeleteDialogOpen(false); + initializedFormKeyRef.current = null; + setEnvironmentForm(createEmptyEnvironmentForm()); + setEnvironmentFormBaselineKey(null); + setEnvironmentVariablesDirty(false); + navigate(ENVIRONMENTS_PATH, { replace: true }); + const destroyedCount = environment.destroyedReusableSandboxLeaseCount ?? 0; + pushToast({ + title: "Environment deleted", + body: + destroyedCount > 0 + ? `${environment.name} was deleted. Destroyed ${destroyedCount === 1 ? "1 reusable sandbox" : `${destroyedCount} reusable sandboxes`}.` + : `${environment.name} was deleted.`, + tone: "success", + }); + }, + onError: async (error, input) => { + // Agents reassigned before the failure keep their new target; refresh so + // the dialog reflects the actual remaining usage. + if (selectedCompanyId && input.reassignAgentIds.length > 0) { + await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); + } + await queryClient.invalidateQueries({ + queryKey: ["environment-delete-blast-radius", input.environment.id], + }); + pushToast({ + title: "Failed to delete environment", + body: error instanceof Error ? error.message : "Environment delete failed.", + tone: "error", + }); + }, + }); + const environmentProbeMutation = useMutation({ mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId, selectedCompanyId), onMutate: (environmentId) => { @@ -1745,6 +1858,86 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) instanceSettings?.defaultEnvironmentId ?? null, savedEnvironments, ); + const instanceDefaultEnvironment = + savedEnvironments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null; + + const deleteBlastRadius = deleteBlastRadiusQuery.data ?? null; + // Reusable sandbox leases are a soft blocker: with explicit consent the + // delete destroys those sandboxes inline. Any other reason is a hard block. + const reusableLeaseOnlyBlock = + deleteBlastRadius !== null && + deleteBlastRadius.deleteBlockedReasons.length > 0 && + deleteBlastRadius.deleteBlockedReasons.every((reason) => reason === "reusable_sandbox_lease"); + const deleteBlockMessage = + deleteBlastRadius && !reusableLeaseOnlyBlock ? environmentDeleteBlockMessage(deleteBlastRadius) : null; + const deleteUsageLoading = deleteBlastRadiusQuery.isPending || companyAgentsQuery.isPending; + const deleteUsageError = deleteBlastRadiusQuery.isError || companyAgentsQuery.isError; + // Environments are instance-scoped while the agent list is company-scoped, so + // this covers only the agents the current company context can reassign. + // References the list cannot see (other companies, terminated agents) fall + // back to the instance default via the FK's on-delete-set-null. + const agentsUsingEnvironment = editingEnvironmentId + ? (companyAgentsQuery.data ?? []).filter( + (agent) => agent.status !== "terminated" && agent.defaultEnvironmentId === editingEnvironmentId, + ) + : []; + const reassignTargetEnvironments = nonLocalEnvironments.filter( + (environment) => environment.id !== editingEnvironmentId, + ); + const deleteImpactNotes: string[] = []; + if (deleteBlastRadius && !deleteBlockMessage) { + if (deleteBlastRadius.staticReferences.agentDefaultCount > agentsUsingEnvironment.length) { + deleteImpactNotes.push( + "Other references to this environment (agents in other companies or terminated agents) fall back to the instance default.", + ); + } + const selectionCount = + deleteBlastRadius.staticReferences.executionWorkspaceSelectionCount + + deleteBlastRadius.staticReferences.issueSelectionCount + + deleteBlastRadius.staticReferences.projectSelectionCount; + if (selectionCount > 0) { + deleteImpactNotes.push( + `${selectionCount} workspace, issue, or project environment ${selectionCount === 1 ? "selection" : "selections"} will be cleared.`, + ); + } + if (deleteBlastRadius.staticReferences.secretBindingCount > 0) { + deleteImpactNotes.push( + `${deleteBlastRadius.staticReferences.secretBindingCount} secret ${deleteBlastRadius.staticReferences.secretBindingCount === 1 ? "binding" : "bindings"} will be removed.`, + ); + } + if (deleteBlastRadius.activeRuntimeUse.hasActiveRuntimeUse) { + deleteImpactNotes.push("Active runs or sandbox leases currently resolve to this environment."); + } + } + // One row per workspace holding blocking sandbox leases (several leases can + // share a workspace). A lease whose workspace FK was nulled groups alone. + const reusableLeaseHolderGroups = (() => { + const groups = new Map< + string, + { workspaceId: string | null; label: string; issueLabels: string[]; leaseCount: number } + >(); + for (const holder of deleteBlastRadius?.reusableSandboxLeaseHolders ?? []) { + const key = holder.executionWorkspaceId ?? `lease:${holder.leaseId}`; + const issueLabel = holder.issueIdentifier ?? holder.issueTitle; + const existing = groups.get(key); + if (existing) { + existing.leaseCount += 1; + if (issueLabel && !existing.issueLabels.includes(issueLabel)) existing.issueLabels.push(issueLabel); + } else { + groups.set(key, { + workspaceId: holder.executionWorkspaceId, + label: + holder.executionWorkspaceName + ?? holder.issueIdentifier + ?? holder.issueTitle + ?? "Workspace no longer on record", + issueLabels: issueLabel ? [issueLabel] : [], + leaseCount: 1, + }); + } + } + return Array.from(groups.values()); + })(); if (!selectedCompanyId) { return
Select a company context to manage environment secrets and bindings.
; @@ -1990,13 +2183,29 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
-
+
+ {editingEnvironment ? ( + + ) : null}

{editingEnvironmentId ? "Edit environment" : "Add environment"}

@@ -2276,6 +2485,138 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) : "Create environment"}

+ + {editingEnvironment ? ( + { + if (!open && deleteEnvironmentMutation.isPending) return; + setDeleteDialogOpen(open); + }} + > + + + Delete {editingEnvironment.name}? + + {deleteUsageLoading + ? "Checking what uses this environment..." + : deleteUsageError + ? "Could not check what uses this environment. Close this dialog and retry." + : deleteBlockMessage + ?? ([ + reusableLeaseOnlyBlock && deleteBlastRadius + ? `${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 reusable sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} reusable sandboxes`} will be destroyed; the workspaces holding them stay open and provision a fresh sandbox on their next run.` + : null, + agentsUsingEnvironment.length > 0 + ? `${agentsUsingEnvironment.length === 1 ? "1 agent uses" : `${agentsUsingEnvironment.length} agents use`} this environment as their default. Choose the environment those agents should be reassigned to.` + : null, + ] + .filter(Boolean) + .join(" ") + || "This environment will be permanently deleted and future runs stop resolving to it.")} + + + {reusableLeaseHolderGroups.length > 0 ? ( +
+
Sandbox leases held by
+
    + {reusableLeaseHolderGroups.map((group) => ( +
  • + {group.workspaceId ? ( + + {group.label} + + ) : ( + {group.label} + )} + + {" "} + · {group.leaseCount === 1 ? "1 sandbox lease" : `${group.leaseCount} sandbox leases`} + {group.issueLabels.length > 0 ? ` · ${group.issueLabels.join(", ")}` : ""} + +
  • + ))} +
+
+ {reusableLeaseOnlyBlock + ? "Deleting destroys these sandboxes; the workspaces stay open." + : "Close these workspaces to let Paperclip destroy their sandboxes, then retry the delete."} +
+
+ ) : null} + {!deleteUsageLoading && !deleteUsageError && !deleteBlockMessage ? ( +
+ {agentsUsingEnvironment.length > 0 ? ( + + ) : null} + {deleteImpactNotes.length > 0 ? ( +
    + {deleteImpactNotes.map((note) => ( +
  • {note}
  • + ))} +
+ ) : null} +
+ ) : null} + + Cancel + { + event.preventDefault(); + deleteEnvironmentMutation.mutate({ + environment: editingEnvironment, + reassignAgentIds: agentsUsingEnvironment.map((agent) => agent.id), + reassignTargetId: reassignEnvironmentTargetId || null, + destroyReusableLeases: reusableLeaseOnlyBlock, + }); + }} + > + {deleteEnvironmentMutation.isPending + ? "Deleting..." + : reusableLeaseOnlyBlock && deleteBlastRadius + ? `Destroy ${deleteBlastRadius.reusableSandboxLeaseCount === 1 ? "1 sandbox" : `${deleteBlastRadius.reusableSandboxLeaseCount} sandboxes`} and delete` + : "Delete environment"} + + +
+
+ ) : null}
) : null}