mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(environments): pass company context to sandbox probes
This commit is contained in:
1 parent
4d898aa7af
commit
6aed879611
5 files changed
+148
-4
No files matched your search
@@ -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]!);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -65,7 +65,10 @@ export const environmentsApi = {
|
||||
config?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) => api.patch<Environment>(`/environments/${environmentId}`, body),
|
||||
probe: (environmentId: string) => api.post<EnvironmentProbeResult>(`/environments/${environmentId}/probe`, {}),
|
||||
probe: (environmentId: string, companyId?: string | null) => {
|
||||
const query = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
||||
return api.post<EnvironmentProbeResult>(`/environments/${environmentId}/probe${query}`, {});
|
||||
},
|
||||
probeConfig: (companyId: string, body: {
|
||||
name?: string;
|
||||
driver: "local" | "ssh" | "sandbox" | "plugin";
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanyEnvironments />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -199,6 +199,36 @@ function readEnvironmentSandboxProvider(environment: Environment): string | null
|
||||
: null;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readProbeMetadata(probe: EnvironmentProbeResult): Record<string, unknown> {
|
||||
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() {
|
||||
</div>
|
||||
{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() {
|
||||
}
|
||||
>
|
||||
<div className="font-medium">{probe.summary}</div>
|
||||
{sandboxLifecycleNote ? (
|
||||
<div className="mt-1 text-muted-foreground">{sandboxLifecycleNote}</div>
|
||||
) : null}
|
||||
{probe.details?.error && typeof probe.details.error === "string" ? (
|
||||
<div className="mt-1 font-mono text-(length:--text-micro)">{probe.details.error}</div>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user