diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 773ce672a05..c2aa6f0316d 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -2,7 +2,7 @@ import type { Server } from "node:http"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { unprocessable } from "../errors.js"; +import { conflict, unprocessable } from "../errors.js"; import { environmentRoutes } from "../routes/environments.js"; import { errorHandler } from "../middleware/index.js"; @@ -12,6 +12,12 @@ const mockAccessService = vi.hoisted(() => ({ decide: vi.fn(), })); +const mockComputerAttach = vi.hoisted(() => vi.fn()); +vi.mock("../modules/computers/index.js", async (importOriginal) => ({ + ...await importOriginal(), + computerService: () => ({ attach: mockComputerAttach }), +})); + const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), })); @@ -278,6 +284,7 @@ describe("environment routes", () => { }); beforeEach(() => { + mockComputerAttach.mockReset(); mockAccessService.canUser.mockReset(); mockAccessService.hasPermission.mockReset(); mockAccessService.decide.mockReset(); @@ -2670,6 +2677,37 @@ describe("environment routes", () => { expect(mockEnvironmentService.list).not.toHaveBeenCalled(); }); + it.each([false, true])("keeps a Boat unavailable until reconnect succeeds (attach succeeds: %s)", async (succeeds) => { + let environment = { + ...createEnvironment(), + driver: "computer", + status: "archived" as "active" | "archived", + config: { + provider: "boat", + sandboxId: "bx_retained", + apiKeySecretRef: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111" }, + }, + metadata: { computerCompanyId: "company-1" }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.update.mockImplementation(async (_id, patch) => { + environment = { ...environment, ...patch }; + return environment; + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBoatEnvironments: true }); + mockComputerAttach.mockImplementation(async () => { + expect(environment.status).toBe("archived"); + if (!succeeds) throw conflict("Computer cleanup is pending"); + return { computerId: "computer-1" }; + }); + const app = createApp({ type: "board", userId: "user-1", source: "local_implicit" }); + const res = await request(app).patch(`/api/environments/${environment.id}`).send({ status: "active" }); + expect(res.status, JSON.stringify(res.body)).toBe(succeeds ? 200 : 409); + expect(mockComputerAttach).toHaveBeenCalledOnce(); + expect(environment.status).toBe(succeeds ? "active" : "archived"); + if (!succeeds) expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("logs a redacted update summary instead of raw config or metadata", async () => { const environment = createEnvironment(); mockEnvironmentService.getById.mockResolvedValue(environment); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index f831c25c313..5794ecc5270 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -1216,6 +1216,11 @@ export function environmentRoutes( : existing.config; const patch = { ...req.body, + // A reconnect becomes available only after the existing computer's + // cleanup and attachment checks succeed. + ...(existing.driver === "computer" && req.body.status === "active" + ? { status: existing.status } + : {}), ...(req.body.envVars !== undefined ? { envVars: await secrets.normalizeEnvBindingsForPersistence( diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 4541780520d..38fe6b5a20b 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -102,17 +102,20 @@ function environmentEditPath(environmentId: string) { // 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 { +function environmentDeleteBlockMessage(impact: EnvironmentDeleteBlastRadius, isComputer = false): string | null { if (impact.staticReferences.isManagedLocal) { return "Cannot delete the managed local environment."; } if (impact.staticReferences.isInstanceDefault) { + if (isComputer) return "Choose another default environment before disconnecting this computer."; return "Cannot delete the current instance default environment. Set a new default environment before deleting this one."; } if (impact.pendingCleanupLeaseCount > 0) { + if (isComputer) return "Computer cleanup is pending. Wait for it to finish, then retry disconnecting. Its files will remain on Boat."; 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) { + if (isComputer) return "This computer still has saved runner connections. Disconnecting stops its runners and retains its files on Boat."; 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; @@ -1609,13 +1612,28 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) queryKey: ["environment-delete-blast-radius", input.environment.id], }); pushToast({ - title: "Failed to delete environment", - body: error instanceof Error ? error.message : "Environment delete failed.", + title: input.environment.driver === "computer" ? "Failed to disconnect computer" : "Failed to delete environment", + body: error instanceof Error ? error.message : input.environment.driver === "computer" ? "Computer disconnect failed. Close this dialog and retry." : "Environment delete failed.", tone: "error", }); }, }); + const reconnectComputerMutation = useMutation({ + mutationFn: (environment: Environment) => + environmentsApi.update(environment.id, { status: "active" }, selectedCompanyId), + onSuccess: async (environment) => { + if (selectedCompanyId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.environments.list(selectedCompanyId) }); + } + setProbeResults((current) => ({ ...current, [environment.id]: null })); + pushToast({ title: "Computer connected", body: `${environment.name} is ready to use with its retained files.`, tone: "success" }); + }, + onError: (error) => { + pushToast({ title: "Failed to connect computer", body: error instanceof Error ? error.message : "Computer connection failed. Try again.", tone: "error" }); + }, + }); + const environmentProbeMutation = useMutation({ mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId, selectedCompanyId), onMutate: (environmentId) => { @@ -1880,7 +1898,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) ? environmentCapabilities?.sandboxProviders?.[editingSandboxProvider] : null; const editingSandboxDisplayName = editingSandboxCapability?.displayName ?? editingSandboxProvider ?? "sandbox"; - const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment)); + const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment) && + (environment.driver !== "computer" || environment.status === "active")); const instanceDefaultEnvironmentId = normalizeNonLocalEnvironmentId( instanceSettings?.defaultEnvironmentId ?? null, savedEnvironments, @@ -1896,7 +1915,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) deleteBlastRadius.deleteBlockedReasons.length > 0 && deleteBlastRadius.deleteBlockedReasons.every((reason) => reason === "reusable_sandbox_lease"); const deleteBlockMessage = - deleteBlastRadius && !reusableLeaseOnlyBlock ? environmentDeleteBlockMessage(deleteBlastRadius) : null; + deleteBlastRadius && !reusableLeaseOnlyBlock ? environmentDeleteBlockMessage(deleteBlastRadius, editingEnvironment?.driver === "computer") : 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 @@ -1933,7 +1952,9 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) ); } if (deleteBlastRadius.activeRuntimeUse.hasActiveRuntimeUse) { - deleteImpactNotes.push("Active runs or sandbox leases currently resolve to this environment."); + deleteImpactNotes.push(editingEnvironment?.driver === "computer" + ? "Active runs and warm runners on this computer will be stopped." + : "Active runs or sandbox leases currently resolve to this environment."); } } // One row per workspace holding blocking sandbox leases (several leases can @@ -1963,7 +1984,7 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) }); } } - return Array.from(groups.values()); + return Array.from(groups, ([key, group]) => ({ ...group, key })); })(); if (!selectedCompanyId) { @@ -2025,7 +2046,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps)
{savedEnvironments.map((environment) => { - const probe = probeResults[environment.id] ?? null; + const computerDisconnected = environment.driver === "computer" && environment.status === "archived"; + const probe = computerDisconnected ? null : probeResults[environment.id] ?? null; const sandboxProvider = readEnvironmentSandboxProvider(environment); const sandboxProviderCapability = sandboxProvider ? environmentCapabilities?.sandboxProviders?.[sandboxProvider] @@ -2046,6 +2068,9 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) · {environment.driver === "computer" ? "Boat" : environment.driver} )} + {computerDisconnected ? ( + Disconnected + ) : null} {isPlatformManagedEnvironment(environment) ? ( @@ -2062,7 +2087,9 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) {typeof environment.config.username === "string" ? environment.config.username : "user"}
) : environment.driver === "computer" ? ( -
Persistent agent folders and a shared desktop on Boat.
+
{computerDisconnected + ? "Your computer and files are retained on Boat. Reconnect to use them again." + : "Persistent agent folders and a shared desktop on Boat."}
) : environment.driver === "sandbox" ? (
{(() => { @@ -2082,7 +2109,16 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) )}
- {environment.driver !== "local" ? ( + {computerDisconnected ? ( + + ) : environment.driver !== "local" ? ( - {editingEnvironment ? ( + {editingEnvironment && !(editingEnvironment.driver === "computer" && editingEnvironment.status === "archived") ? ( @@ -2562,8 +2602,8 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) ? "Could not check what uses this environment. Close this dialog and retry." : deleteBlockMessage ?? ([ - editingEnvironment.driver === "computer" ? "Paperclip will stop using this computer. Its filesystem will be retained on Boat." : null, - reusableLeaseOnlyBlock && deleteBlastRadius + editingEnvironment.driver === "computer" ? "Paperclip will stop this computer’s runners and disconnect it. The computer and its files will remain on Boat. Workspaces stay open." : null, + editingEnvironment.driver !== "computer" && 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 @@ -2575,12 +2615,12 @@ export function CompanyEnvironments({ mode = "list" }: CompanyEnvironmentsProps) || "This environment will be permanently deleted and future runs stop resolving to it.")} - {reusableLeaseHolderGroups.length > 0 ? ( + {editingEnvironment.driver !== "computer" && reusableLeaseHolderGroups.length > 0 ? (
Sandbox leases held by
    {reusableLeaseHolderGroups.map((group) => ( -
  • +
  • {group.workspaceId ? (