diff --git a/doc/project-environment-compatibility.md b/doc/project-environment-compatibility.md index 47ed72bb872..39ac8854044 100644 --- a/doc/project-environment-compatibility.md +++ b/doc/project-environment-compatibility.md @@ -63,8 +63,12 @@ The hidden repository-free “My private tasks” project is independent of environment/storage availability, so creating a private note never requires repairing an environment. -An unavailable configured default produces an error and permits an explicit -available choice. It does not silently select another storage owner. Existing +When the configured default is unavailable and only one storage owner is +available, that owner becomes the default, because there is nothing to choose. +When two or more owners are available, an unavailable default produces an error +and permits an explicit available choice. It does not silently select another +storage owner. The UI shows the **Available in** selector only when there are two +or more storage owners. Existing projects keep their stored owner. This change does not migrate managed storage or detach projects that contain existing work. A managed project run in an incompatible environment fails with `project_environment_incompatible` before a diff --git a/docs/api/goals-and-projects.md b/docs/api/goals-and-projects.md index 2436c9a4daf..e508f997a72 100644 --- a/docs/api/goals-and-projects.md +++ b/docs/api/goals-and-projects.md @@ -86,8 +86,9 @@ Notes: - `environmentId` optionally selects the project's workspace storage: the storage that this environment uses. When omitted, storage follows the creator's effective - default environment. The project does not save the environment, and it does not - change where runs execute. + default environment. If that default is unavailable and only one storage owner is + available, the project uses that owner. The project does not save the environment, + and it does not change where runs execute. - `expectedStorageProviderPluginId` optionally guards the observed storage owner. Use `GET /api/companies/{companyId}/project-environments` to discover compatible groups and the creator's default. Unknown or changed storage is rejected. diff --git a/server/src/__tests__/project-environments.test.ts b/server/src/__tests__/project-environments.test.ts index 53680272e43..db951894ed6 100644 --- a/server/src/__tests__/project-environments.test.ts +++ b/server/src/__tests__/project-environments.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { eq, sql } from "drizzle-orm"; +import { eq, inArray, sql } from "drizzle-orm"; import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, userCompanyPreferences } from "@paperclipai/db"; import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js"; import { assertProjectStorageEnvironment, projectStorageForEnvironment } from "../services/project-workspace-provider.js"; @@ -172,16 +172,40 @@ describe.skipIf(!support.supported)("project environment compatibility", () => { } }); - it("keeps a cold worker's storage identity, but refuses an archived default", async () => { + it("keeps a cold worker's storage identity, and uses the only storage left after its default is archived", async () => { const f = await fixture(); await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id }); await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId)); expect(await withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId }); await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id)); - expect((await projectEnvironmentOptions(db, f.companyId)).error).toBeTruthy(); + expect(await projectEnvironmentOptions(db, f.companyId)).toMatchObject({ defaultEnvironmentId: f.ssh.id, error: null }); + expect(await withDefaultProjectStorage(db, f.companyId, null)).toBeNull(); + }); + + it("refuses to guess storage when the default is unavailable and several storage options remain", async () => { + const f = await fixture(); + const [fake] = await db.insert(environments).values({ name: "Fake", driver: "sandbox", config: { provider: "fake" } }).returning(); + await db.update(instanceSettings).set({ defaultEnvironmentId: fake.id }); + const options = await projectEnvironmentOptions(db, f.companyId); + expect(options.groups).toHaveLength(2); + expect(options.defaultEnvironmentId).toBeNull(); + expect(options.error).toBeTruthy(); await expect(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 }); }); + it("uses agent computer storage without a choice when managed-only leaves no other environment", async () => { + const f = await fixture(); + await db.update(environments).set({ status: "archived" }).where(inArray(environments.id, [f.sandbox.id, f.ssh.id])); + await db.update(instanceSettings).set({ experimental: { enableManagedSandboxOnly: true } }); + const options = await projectEnvironmentOptions(db, f.companyId); + expect(options).toEqual({ + groups: [{ storageProviderPluginId: f.pluginId, environments: [{ id: f.computer.id, name: f.computer.name }] }], + defaultEnvironmentId: f.computer.id, + error: null, + }); + expect(await withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId }); + }); + it.each(["instance", "primary"] as const)("creates repository-free private tasks despite an archived %s default", async source => { const f = await fixture(); const userId = randomUUID(); diff --git a/server/src/services/project-environments.ts b/server/src/services/project-environments.ts index 3115a5da36c..ad97e410a9c 100644 --- a/server/src/services/project-environments.ts +++ b/server/src/services/project-environments.ts @@ -61,10 +61,11 @@ export async function projectEnvironmentOptions(db: Db, companyId: string, actor for (const group of groups) group.environments.sort((a, b) => a.name.localeCompare(b.name)); groups.sort((a, b) => a.environments[0].name.localeCompare(b.environments[0].name)); const defaultAvailable = preferred && groups.some(group => group.environments.some(environment => environment.id === preferred.id)); + const defaultEnvironmentId = defaultAvailable ? preferred!.id : groups.length === 1 ? groups[0].environments[0].id : null; return { groups, - defaultEnvironmentId: defaultAvailable ? preferred?.id ?? null : null, - error: !defaultAvailable && (preferredEnvironmentId || forcedKubernetes || managedOnly || environments.length > 0) + defaultEnvironmentId, + error: !defaultEnvironmentId && (preferredEnvironmentId || forcedKubernetes || managedOnly || environments.length > 0) ? "The default environment cannot use project workspace storage. Select an available environment or repair the default environment configuration." : null, }; diff --git a/ui/src/components/NewProjectDialog.test.tsx b/ui/src/components/NewProjectDialog.test.tsx index 0923f7a9ae9..52758177e3c 100644 --- a/ui/src/components/NewProjectDialog.test.tsx +++ b/ui/src/components/NewProjectDialog.test.tsx @@ -131,6 +131,13 @@ describe("project environment selection", () => { expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" })); }); + it("never offers a single storage option, even when the default is unavailable", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups: [groups[1]], defaultEnvironmentId: null, error: "Default is unavailable" }); + await renderForm(); + expect(container.querySelector("select")).toBeNull(); + expect(container.querySelector("#project-environment")).toBeNull(); + }); + it("blocks submission while discovery is pending", async () => { let resolve!: (value: Awaited>) => void; vi.mocked(projectsApi.environmentOptions).mockReturnValue(new Promise(value => { resolve = value; })); @@ -168,6 +175,21 @@ describe("project environment selection", () => { expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); }); + it("uses the only remaining storage when a refresh removes the chosen one", async () => { + vi.mocked(projectsApi.environmentOptions).mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null }) + .mockResolvedValue({ groups: [groups[0]], defaultEnvironmentId: "ssh", error: null }); + const client = await renderForm(); + const select = container.querySelector("select")!; + act(() => { select.value = "computer"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await act(async () => { await client.refetchQueries({ queryKey: queryKeys.projects.environments("company-1") }); }); + await settle(); + expect(container.querySelector("select")).toBeNull(); + expect(container.querySelector('button[type="submit"]')!.disabled).toBe(false); + act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); + await settle(); + expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh", expectedStorageProviderPluginId: null })); + }); + it("does not silently substitute storage when the default is unavailable", async () => { vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: null, error: "Default is unavailable" }); await renderForm(); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index ce169136bf3..074f954453d 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -34,8 +34,10 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl const environments = useQuery({ queryKey: queryKeys.projects.environments(companyId), queryFn: () => projectsApi.environmentOptions(companyId), gcTime: 0 }); const environmentView = useQueryView(environments); const options = environmentView.data; - const environmentId = selectedEnvironmentId ?? options?.defaultEnvironmentId; - const selectedGroup = options?.groups.find(group => group.environments.some(environment => environment.id === environmentId)); + const groupOf = (id: string | null | undefined) => options?.groups.find(group => group.environments.some(environment => environment.id === id)); + const onlyGroupRemains = options?.groups.length === 1 && Boolean(selectedEnvironmentId) && !groupOf(selectedEnvironmentId); + const environmentId = onlyGroupRemains ? options?.defaultEnvironmentId : selectedEnvironmentId ?? options?.defaultEnvironmentId; + const selectedGroup = groupOf(environmentId); const environmentReady = Boolean(environmentView.kind !== "error" && options && (!options.error || selectedGroup) && (!options.groups.length || selectedGroup)); const create = useMutation({ @@ -69,7 +71,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
- {options && (options.groups.length > 1 || (options.error && options.groups.length > 0)) &&
+ {options && options.groups.length > 1 &&
setSelectedEnvironmentId(event.target.value)}>