feat: environment delete with agent reassignment and consented sandbox destroy (#12053)
## 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
This commit is contained in:
parent
627eef7cbd
commit
c62bb4b16b
|
|
@ -669,6 +669,7 @@ export type {
|
|||
WriteSummarySlotResponse,
|
||||
Environment,
|
||||
EnvironmentDeleteBlastRadius,
|
||||
EnvironmentDeleteReusableLeaseHolder,
|
||||
EnvironmentDeleteBlockedReason,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export { DECISION_TRAINING_RETENTION_POLICY } from "./decision-training.js";
|
|||
export type {
|
||||
Environment,
|
||||
EnvironmentDeleteBlastRadius,
|
||||
EnvironmentDeleteReusableLeaseHolder,
|
||||
EnvironmentDeleteBlockedReason,
|
||||
EnvironmentLease,
|
||||
EnvironmentProbeResult,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
|
|
@ -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<typeof getActorInfo>;
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<PluginEnvironmentLease | EnvironmentLease | null> {
|
||||
const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment));
|
||||
if (!driver.resumeRunLease) {
|
||||
|
|
|
|||
|
|
@ -1230,8 +1230,17 @@ export function environmentService(db: Db) {
|
|||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`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,
|
||||
|
|
|
|||
|
|
@ -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<EnvironmentLease>(`/environment-leases/${leaseId}`),
|
||||
secretRefs: (environmentId: string) =>
|
||||
api.get<{ refs: EnvironmentSecretRefDescriptor[] }>(`/environments/${environmentId}/secret-refs`),
|
||||
deleteBlastRadius: (environmentId: string) =>
|
||||
api.get<EnvironmentDeleteBlastRadius>(`/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<Environment & { destroyedReusableSandboxLeaseCount?: number }>(
|
||||
options.destroyReusableSandboxLeases
|
||||
? `/environments/${environmentId}?destroyReusableSandboxLeases=true`
|
||||
: `/environments/${environmentId}`,
|
||||
),
|
||||
create: (companyId: string, body: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function createDeleteBlastRadius(overrides: {
|
||||
canDelete?: boolean;
|
||||
deleteBlockedReasons?: string[];
|
||||
staticReferences?: Record<string, unknown>;
|
||||
activeRuntimeUse?: Record<string, unknown>;
|
||||
pendingCleanupLeaseCount?: number;
|
||||
reusableSandboxLeaseCount?: number;
|
||||
reusableSandboxLeaseHolders?: Array<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
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<HTMLButtonElement>("[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<HTMLElement>("[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<HTMLSelectElement>(
|
||||
"[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<HTMLButtonElement>(
|
||||
"[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<HTMLElement>(
|
||||
"[data-testid='environment-delete-lease-holders']",
|
||||
)!;
|
||||
const workspaceLink = holders.querySelector<HTMLAnchorElement>("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<HTMLButtonElement>(
|
||||
"[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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Record<string, EnvironmentProbeResult | null>>({});
|
||||
const [testingEnvironmentId, setTestingEnvironmentId] = useState<string | null>(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 <div className="text-sm text-muted-foreground">Select a company context to manage environment secrets and bindings.</div>;
|
||||
|
|
@ -1990,13 +2183,29 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
<SecretRefHintsContext.Provider value={environmentSecretRefHints}>
|
||||
<div data-testid="environment-form-page">
|
||||
<div className="pb-4">
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link to={ENVIRONMENTS_PATH}>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Environments
|
||||
</Link>
|
||||
</Button>
|
||||
{editingEnvironment ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Delete ${editingEnvironment.name}`}
|
||||
title="Delete environment"
|
||||
data-testid="environment-delete-button"
|
||||
onClick={() => {
|
||||
setReassignEnvironmentTargetId("");
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold">{editingEnvironmentId ? "Edit environment" : "Add environment"}</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
|
||||
|
|
@ -2276,6 +2485,138 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
|
|||
: "Create environment"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{editingEnvironment ? (
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && deleteEnvironmentMutation.isPending) return;
|
||||
setDeleteDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-testid="environment-delete-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {editingEnvironment.name}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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.")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{reusableLeaseHolderGroups.length > 0 ? (
|
||||
<div className="space-y-1.5" data-testid="environment-delete-lease-holders">
|
||||
<div className="text-xs font-medium text-muted-foreground">Sandbox leases held by</div>
|
||||
<ul className="space-y-1">
|
||||
{reusableLeaseHolderGroups.map((group) => (
|
||||
<li key={group.workspaceId ?? group.label} className="text-sm">
|
||||
{group.workspaceId ? (
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
to={`/execution-workspaces/${group.workspaceId}`}
|
||||
>
|
||||
{group.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{group.label}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{" "}
|
||||
· {group.leaseCount === 1 ? "1 sandbox lease" : `${group.leaseCount} sandbox leases`}
|
||||
{group.issueLabels.length > 0 ? ` · ${group.issueLabels.join(", ")}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{reusableLeaseOnlyBlock
|
||||
? "Deleting destroys these sandboxes; the workspaces stay open."
|
||||
: "Close these workspaces to let Paperclip destroy their sandboxes, then retry the delete."}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!deleteUsageLoading && !deleteUsageError && !deleteBlockMessage ? (
|
||||
<div className="space-y-3">
|
||||
{agentsUsingEnvironment.length > 0 ? (
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="font-medium">
|
||||
Reassign {agentsUsingEnvironment.length === 1 ? "agent" : "agents"} to
|
||||
</span>
|
||||
<select
|
||||
aria-label="Reassign agents to environment"
|
||||
data-testid="environment-delete-reassign-select"
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-normal outline-none"
|
||||
value={reassignEnvironmentTargetId}
|
||||
onChange={(event) => setReassignEnvironmentTargetId(event.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
Default: {instanceDefaultEnvironment
|
||||
? `${instanceDefaultEnvironment.name} · ${instanceDefaultEnvironment.driver}`
|
||||
: "Local"}
|
||||
</option>
|
||||
{reassignTargetEnvironments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name} · {environment.driver}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Affected: {agentsUsingEnvironment.map((agent) => agent.name).join(", ")}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{deleteImpactNotes.length > 0 ? (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
{deleteImpactNotes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteEnvironmentMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="environment-delete-confirm"
|
||||
disabled={
|
||||
deleteEnvironmentMutation.isPending ||
|
||||
deleteUsageLoading ||
|
||||
deleteUsageError ||
|
||||
Boolean(deleteBlockMessage)
|
||||
}
|
||||
onClick={(event) => {
|
||||
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"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</SecretRefHintsContext.Provider>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Reference in New Issue