mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
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:
1 parent
76da2fec4f
commit
14cb69245f
6 files changed
+66
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in new issue
Block a user