diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 883e4c7f32d..ae0e10dd8bf 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -419,6 +419,46 @@ describeEmbeddedPostgres("environmentCustomImageService", () => { })).rejects.toThrow("Setup template must be the active template"); }); + it("turns missing provider template setup failures into conflicts", async () => { + const { environmentId } = await seed(); + const workerManager = createWorkerManager(); + workerManager.call.mockImplementation(async (_pluginId, method) => { + if (method === "environmentStartInteractiveSetup") { + throw new Error("Snapshot not found"); + } + throw new Error(`Unexpected plugin call: ${method}`); + }); + const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager }); + const [template] = await db.insert(environmentCustomImageTemplates).values({ + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-missing", + status: "active", + }).returning(); + + await expect(service.startSetupSession({ + environmentId, + templateId: template!.id, + actor: { userId: "user-1" }, + })).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("customImage template no longer exists"), + }); + + const [session] = await db + .select({ + status: environmentCustomImageSetupSessions.status, + failureReason: environmentCustomImageSetupSessions.failureReason, + }) + .from(environmentCustomImageSetupSessions) + .where(eq(environmentCustomImageSetupSessions.templateId, template!.id)); + expect(session).toEqual({ + status: "failed", + failureReason: "Selected provider template is missing.", + }); + }); + it("applies the active template regardless of base-config changes and falls back when none exists", async () => { const { companyId, environmentId } = await seed(); const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!); diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index 1cef716cf5f..f27bf8a1ffb 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -185,6 +185,24 @@ function isActiveSetupStatus(status: EnvironmentCustomImageSetupSessionStatus): return (ACTIVE_SETUP_STATUSES as readonly string[]).includes(status); } +function providerErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isProviderMissingTemplateError(error: unknown): boolean { + const message = providerErrorMessage(error).toLowerCase(); + return ( + (message.includes("snapshot") || message.includes("template") || message.includes("image")) + && (message.includes("not found") || message.includes("missing")) + ); +} + +function missingProviderTemplateConflict() { + return conflict( + "The selected environment customImage template no longer exists in the sandbox provider. Disable this custom image or roll back to a previous template, then start image setup again.", + ); +} + function templateConfigBindingFromDriver(input: { templateRefKind?: string | null | undefined; templateConfigBinding?: unknown; @@ -700,11 +718,17 @@ export function environmentCustomImageService( connectionPayload: providerSession.connectionPayload ?? null, }; } catch (error) { + const failureReason = selectedTemplate && isProviderMissingTemplateError(error) + ? "Selected provider template is missing." + : providerErrorMessage(error); await markSessionStatus({ sessionId, status: "failed", - failureReason: error instanceof Error ? error.message : String(error), + failureReason, }); + if (selectedTemplate && isProviderMissingTemplateError(error)) { + throw missingProviderTemplateConflict(); + } throw error; } }, diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index ab3cf09aaa5..c8f620e03c7 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -65,7 +65,10 @@ export const environmentsApi = { config?: Record; metadata?: Record | null; }) => api.patch(`/environments/${environmentId}`, body), - probe: (environmentId: string) => api.post(`/environments/${environmentId}/probe`, {}), + probe: (environmentId: string, companyId?: string | null) => { + const query = companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""; + return api.post(`/environments/${environmentId}/probe${query}`, {}); + }, probeConfig: (companyId: string, body: { name?: string; driver: "local" | "ssh" | "sandbox" | "plugin"; diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index c787bea861d..4b576c9c522 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -485,7 +485,50 @@ describe("CompanyEnvironments — test provider button", () => { expect(buttonsAfter[0].disabled).toBe(true); expect(buttonsAfter[1].textContent?.trim()).toBe("Test provider"); expect(buttonsAfter[1].disabled).toBe(false); - expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1"); + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + + it("explains that successful sandbox provider tests use a temporary sandbox", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.probe.mockResolvedValue({ + ok: true, + driver: "sandbox", + summary: "Connected to Daytona sandbox paperclip-probe.", + details: { + provider: "daytona", + diagnostics: [], + metadata: { + provider: "daytona", + sandboxId: "473167E9", + sandboxName: "paperclip-probe", + }, + }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => { + testProviderButtons(container)[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + expect(container.textContent).toContain("Verified temporary daytona sandbox paperclip-probe (473167E9)."); + expect(container.textContent).toContain("Test probes clean up the validation sandbox after the check"); }); it("keeps the second environment's testing state when an earlier probe settles", async () => { diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index 8b0d3c68806..6229d9b2820 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -199,6 +199,36 @@ function readEnvironmentSandboxProvider(environment: Environment): string | null : null; } +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function readProbeMetadata(probe: EnvironmentProbeResult): Record { + const details = readRecord(probe.details); + return readRecord(details?.metadata) ?? {}; +} + +function readStringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function sandboxProbeLifecycleNote(probe: EnvironmentProbeResult): string | null { + if (probe.driver !== "sandbox") return null; + const metadata = readProbeMetadata(probe); + const provider = readStringValue(metadata.provider) ?? readStringValue(readRecord(probe.details)?.provider) ?? "sandbox"; + const sandboxName = readStringValue(metadata.sandboxName); + const sandboxId = readStringValue(metadata.sandboxId); + const sandboxLabel = sandboxName && sandboxId + ? `${sandboxName} (${sandboxId})` + : sandboxName ?? sandboxId; + const base = sandboxLabel + ? `Verified temporary ${provider} sandbox ${sandboxLabel}.` + : `Verified a temporary ${provider} sandbox.`; + return `${base} Test probes clean up the validation sandbox after the check, so it may not remain visible in the provider dashboard.`; +} + function formatDateTime(value: string | Date | null | undefined): string | null { if (!value) return null; const date = value instanceof Date ? value : new Date(value); @@ -1150,7 +1180,7 @@ export function CompanyEnvironments() { }); const environmentProbeMutation = useMutation({ - mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId), + mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId, selectedCompanyId), onMutate: (environmentId) => { setTestingEnvironmentId(environmentId); }, @@ -1409,6 +1439,7 @@ export function CompanyEnvironments() { {savedEnvironments.map((environment) => { const probe = probeResults[environment.id] ?? null; + const sandboxLifecycleNote = probe ? sandboxProbeLifecycleNote(probe) : null; const isEditing = editingEnvironmentId === environment.id; const sandboxProvider = readEnvironmentSandboxProvider(environment); const sandboxProviderCapability = sandboxProvider @@ -1478,6 +1509,9 @@ export function CompanyEnvironments() { } >
{probe.summary}
+ {sandboxLifecycleNote ? ( +
{sandboxLifecycleNote}
+ ) : null} {probe.details?.error && typeof probe.details.error === "string" ? (
{probe.details.error}
) : null}