fix(projects): do not offer a single project storage option (#20875)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A project uses one workspace storage owner. The user chooses it when
they create the project.
> - The **Available in** selector shows the storage owners that the user
can choose.
> - Sometimes the default environment cannot be used, and only one
storage owner is available. In that case, the dialog shows a selector
with only one option. The user must select it before they can create the
project.
> - A selector with only one option is not a real choice.
> - This pull request uses the only available storage owner as the
default. The selector appears only when the user has two or more
options.
> - The benefit is that project creation asks for a choice only when
there is one.

## Linked Issues or Issue Description

Refs #20835

**What happened?**
An example is an instance in managed-sandbox-only mode that has the
agent-computer plugin but no managed sandbox. The local environment is
hidden, and the managed-only rule finds no default. The
project-environments endpoint returns one storage group and an error.
The new project dialog shows **Available in** with only one option and
disables **Create project** until the user selects it. An API request
without `environmentId` fails with `project_environment_unavailable`.

**What did you expect?**
When only one storage owner is available, the project uses it. No
selector appears.

**Steps to reproduce**
1. Turn on `enableManagedSandboxOnly`. Install the agent-computer
plugin. Do not add a sandbox environment.
2. Open **Create project**.
3. See an **Available in** selector with one option, and a disabled
**Create project** button.

## What Changed

- `projectEnvironmentOptions`: when the configured default is
unavailable and only one storage group exists, the first environment of
that group becomes `defaultEnvironmentId`, and `error` is null. When two
or more groups exist, an unavailable default still produces the error.
The server does not guess.
- Project create uses the same default, so an API request without
`environmentId` now succeeds in the single-group case.
- `NewProjectDialog` shows the selector only when `groups.length > 1`.
- Tests:
- A new server test covers managed-only mode with only the
agent-computer environment.
- A new server test covers an unavailable default with two storage
groups (the server still refuses to guess).
- The archived-default test now expects the server to use the only
remaining storage.
  - A new UI test checks that the dialog never shows one option.
- Docs: `doc/project-environment-compatibility.md` and
`docs/api/goals-and-projects.md`.

## Verification

- `pnpm vitest run src/__tests__/project-environments.test.ts` in
`server/`: 16 passed.
- `pnpm vitest run src/components/NewProjectDialog` in `ui/`: 12 passed.
- `tsc --noEmit` in `ui/`: clean.
- Server `tsc` reports 9 errors on this machine. None are in the changed
files. They are in `packages/adapter-utils` and a native-runtime test,
because local dependencies are stale. CI typechecks with a fresh
install.

## Risks

- Low risk. The change applies only when the default is unavailable and
exactly one storage group exists. In that case, the server picks the
group that the user would have had to choose anyway. Run placement does
not depend on the project, so this does not change where runs execute.
- No migration.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, test runs).

## 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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 5.5 authored and GitHub committed 2026-11-11 09:49:33 -08:00
1 parent 76da2fec4f
commit 14cb69245f
6 files changed
+66 -12

No files matched your search

+6 -2
View File
@@ -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
+3 -2
View File
@@ -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.
@@ -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();
+3 -2
View File
@@ -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,
};
@@ -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<ReturnType<typeof projectsApi.environmentOptions>>) => void;
vi.mocked(projectsApi.environmentOptions).mockReturnValue(new Promise(value => { resolve = value; }));
@@ -168,6 +175,21 @@ describe("project environment selection", () => {
expect(container.querySelector<HTMLButtonElement>('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<HTMLSelectElement>("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<HTMLButtonElement>('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();
+5 -3
View File
@@ -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
</div>
</div>
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
{options && (options.groups.length > 1 || (options.error && options.groups.length > 0)) && <div className="mb-4 space-y-2">
{options && options.groups.length > 1 && <div className="mb-4 space-y-2">
<label htmlFor="project-environment" className="text-sm font-medium">Available in</label>
<NativeSelect id="project-environment" value={selectedGroup?.environments[0].id ?? ""} disabled={create.isPending}
onChange={event => setSelectedEnvironmentId(event.target.value)}>