Choose project storage from compatible environments (#20587)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects supply repository files to agent runs through core staging
or a storage plugin.
> - A storage plugin's default claim silently attached new projects even
when their agents used a different environment.
> - An environment name or driver type does not establish shared storage
access. The existing binding and task-driver contracts do.
> - This pull request groups creation choices by that contract and
selects storage from the creator's effective environment.
> - The benefit is a visible, validated choice and a clear error before
an incompatible managed workspace operation.

## Linked Issues or Issue Description

Fixes #20459.

Related: #20156 covers a separate provider-failure diagnostic. The PR
search found no other fix for #20459. ROADMAP.md has no duplicate
project-creation change.

## What Changed

- Show **Available in** when configured environments require different
project storage. Each option names the environments that can use the
same storage owner. Hide the selector when all choices are compatible.
- Derive compatibility from repository staging and managed binding
ownership. Local, SSH, and sandbox environments use core staging.
Managed storage requires a registered task driver from the same
installed storage plugin. Separate storage plugins remain incompatible.
Unknown plugin contracts and the fake sandbox are excluded.
- Follow the creating agent's environment, or the person's primary
agent, then the instance default and local fallback. Apply managed-only
and forced Kubernetes policy. The product has no separate user or
company environment-default field today.
- Add a company-authorized discovery endpoint and an optional creation
`environmentId`. Validate the choice again on save. Store the storage
owner without overriding agent run environments. Preserve explicit API
storage choices and existing project ownership.
- Keep the hidden repository-free private-task project independent of
environment availability. Submit the observed storage owner as
`expectedStorageProviderPluginId` so a changed contract fails at save
without setting a workspace policy. Instance task-isolation defaults
stay inherited.
- Reject an incompatible managed workspace before a provider operation.
Name the storage and selected environment in the error. Add regression
tests, API documentation, creation-story fixtures, and minimal
corrections to obsolete map text.

## Verification

- **102 tests passed across 7 files:** project environment integration,
installed storage policy, repository persistence, project environment
routes, project creation telemetry routes, OpenAPI, and
NewProjectDialog. Run with `node node_modules/vitest/vitest.mjs run
server/src/__tests__/project-environments.test.ts
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/project-repositories-persistence.test.ts
server/src/__tests__/project-routes-env.test.ts
server/src/__tests__/project-goal-telemetry-routes.test.ts
server/src/__tests__/openapi-routes.test.ts
ui/src/components/NewProjectDialog.test.tsx --silent`.
- The review fixes cover both archived-default private-task cases and
changed-storage rejection. With both instance isolation flags enabled, a
new core repository project keeps a null policy and resolves to an
isolated Git worktree. The complete affected set passes all 102 tests.
- The CI query-error policy failure was fixed by using the shared query
view. The form and shared query-view suites pass **24 tests**, including
cached choices during a transient refetch failure. `node
scripts/check-query-error-rendering.mjs --enforce --summary` reports
zero findings.
- CI exposed stale SDK source hashes and heartbeat fixtures that paired
plugin storage with Local. Refreshed exactly four shared-source hashes
with the maintained generator (runtime pins unchanged). All **53 SDK
privacy/source-proof tests**, including real built SDK output, pass. All
**7 corrected managed-storage heartbeat scenarios** pass; the fixtures
declare the owning plugin's task environment and substitute transport at
the existing execution seam.
- Shared and UI TypeScript checks pass. The UI production build passes.
`pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node
scripts/check-module-boundaries.mjs`, and diff checks pass.
- Tested UI states: compatible choices, incompatible groups, default
within a group, manual override, create failure, loading, discovery
failure and retry, and an unavailable default. Tests use the production
form and existing design tokens.
- The integration tests use real isolated PostgreSQL on macOS arm64 with
Node 26.12.0. They cover user/agent defaults, storage persistence,
policy restrictions, company and authentication denials, and rejection
before a provider call. Discovery uses saved configuration and makes no
worker or provider requests.
- Local verification limits: `pnpm -r typecheck` and `pnpm build` stop
in unchanged adapter-utils code because the installed ACPX types lack
`inheritProcessEnv`. A direct server check also finds an unchanged AI
SDK type-version mismatch. The existing heartbeat test `inherits the
instance default environment across companies while preserving explicit
agent overrides` times out at line 518 on both this change and
unmodified master with the same dependencies. The full `pnpm test:run`
attempt passed all six chat shards (1,948 tests), then the Git workspace
expiration suite reported 14 failures. I stopped that broad run. Two
sampled failures reproduce on unchanged master with the same
`unknown_worktree_git_files` refusal; the other expiration failures were
not independently compared. A later broad workspace-preparation run also
hit workspace validation failures and was stopped; the seven corrected
fixture scenarios pass independently. These limits do not establish a
full local suite pass. Hosted CI remains required.
- Initial missing-package and loopback-denial failures were resolved by
repairing isolated dependency links, building the SDK, and running
database tests with loopback access. No deployed or live-provider
execution is claimed.

## Risks

- Creation defaults now follow the effective environment. A plugin's
`defaultForProjects` claim alone no longer selects storage. This is the
intended behavior change.
- Compatibility covers repository project storage. It does not guarantee
arbitrary host paths, retained-workspace moves, adapter/image support,
credentials, or worker readiness. Matching provider configs cannot prove
a cross-plugin storage contract.
- A person's primary agent supplies the personal default because there
is no separate personal environment setting. An unavailable configured
default requires a valid explicit choice or a configuration repair.
- No migration runs. Existing projects keep their storage owner. This
change does not detach existing managed data or provide a storage
migration.
- Full local verification has the limits listed above. CI and standard
Greptile review are pending.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving snapshot and context-window size were not
exposed in this session.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-11-10 17:38:31 +00:00
1 parent 1c78d023c6
commit 98ce93dd4e
25 files changed
+671 -51

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
# Project environments and repository storage
A repository project belongs to one storage owner. Creation shows **Available in**
when the configured environments require more than one owner. Each choice names
all environments that use that owner. An environment choice selects storage; it
is not an allowlist or an override of an agent's run environment.
## Compatibility rule
Use the existing workspace execution contract, not environment names, driver
names alone, images, operating systems, endpoints, or complete config equality:
- Local, SSH, and sandbox environments use core repository staging. They can use
the same repository project even across driver types and sandbox providers.
- Plugin-managed storage belongs to an installed plugin ID. It can be consumed
only by that plugin's registered `environment_driver` with `supportsTasks: true`.
Its manifest must declare `environment.drivers.register`, `workspaces.manage`,
a workspace provider, and the project resource lifecycle.
- Two task environments from that installed plugin share its project storage
contract, even when their other settings differ. Storage operations are scoped
by company, project, and plugin; they do not take an environment configuration.
Separate installed storage plugins remain incompatible even when both use the
`plugin` driver. Core cannot transfer their managed bindings between them.
- Unknown plugin drivers and the probe-only fake sandbox do not establish a
compatible project storage contract. They are excluded from creation choices.
These are project repository compatibility rules. They do not assert that
arbitrary local paths exist remotely, that a retained task workspace can switch
hosts, or that an adapter, credential, image, or running worker is ready. Existing
run-time validation still checks those conditions. Cross-plugin storage sharing
would require a new execution/storage contract; matching config fields cannot
establish it.
## Defaults and validation
For an agent API request, use that agent's default environment. For a person,
use their primary agent's default environment when present. Fall back to the
instance default, then local execution. There is no separate user or company
`defaultEnvironmentId` setting today. Managed-only and forced Kubernetes policies
retain their execution restrictions and default redirection.
`GET /api/companies/:companyId/project-environments` returns environment IDs and
names grouped by storage owner, plus the caller's effective default. It excludes
archived environments, disabled/uninstalled storage plugins, and managed
environments bound only to another company. It does not contact workers or return
provider configs, credentials, endpoints, or host paths. A cold worker does not
change a durable storage choice.
`POST /api/companies/:companyId/projects` accepts an optional `environmentId`.
The server validates the choice again and stores its storage owner. The project
is then available to the compatible environments in that group; no single run
environment is pinned. An explicit `executionWorkspacePolicy.storageProviderPluginId`
continues to work. The UI sends both the environment and its observed storage
owner in `expectedStorageProviderPluginId`, so a changed environment cannot
silently select different storage. This guard does not create a workspace-mode
policy; instance task-isolation defaults remain inherited. An explicit policy
and environment must agree when both are supplied. Without either,
storage follows the caller's effective environment. A plugin's
`defaultForProjects` claim alone no longer attaches a project to that plugin.
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
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
workspace provider operation, with a message naming the storage and environment.
+3 -3
View File
@@ -29,8 +29,8 @@ Open `/projects`, create a project, and enter its overview or Issues view.
### `repository-settings`
Open `/projects/:projectId/configuration` and its repository/workspace settings.
Workspace storage shows the inherited installed default and allows an explicit
provider or Core/local choice. Conflicting defaults remain visible for repair.
Workspace storage shows the saved provider or Core/local choice. Plugin-backed
projects retain their storage owner.
### `project-access`
@@ -48,7 +48,7 @@ Manual: Create a project, add a task from its view, and reload. Verify the task
### `repository-settings`
Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover inherited choices, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage.
Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover saved storage, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage.
Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows.
+1
View File
@@ -944,6 +944,7 @@ export type {
ProjectBudgetSummary,
ProjectRepository,
ProjectRepositoryOptions,
ProjectEnvironmentOptions,
ProjectGitAuthorization,
ProjectCodebase,
ProjectCodebaseOrigin,
+1
View File
@@ -366,6 +366,7 @@ export type {
Project,
ProjectRepository,
ProjectRepositoryOptions,
ProjectEnvironmentOptions,
ProjectGitAuthorization,
ProjectAccessMember,
ProjectAccessSubjectType,
+9
View File
@@ -107,6 +107,15 @@ export interface ProjectDiscoveryPage {
nextCursor: string | null;
}
export interface ProjectEnvironmentOptions {
groups: Array<{
storageProviderPluginId: string | null;
environments: Array<{ id: string; name: string }>;
}>;
defaultEnvironmentId: string | null;
error: string | null;
}
export interface ProjectWorkspaceProviderConfiguration {
source: "project" | "core";
pluginId: string | null;
@@ -126,6 +126,8 @@ const projectFields = {
export const createProjectSchema = z.object({
idempotencyKey: z.string().trim().min(1).max(255).optional(),
...projectFields,
environmentId: z.string().guid().optional(),
expectedStorageProviderPluginId: z.string().guid().nullable().optional(),
workspace: createProjectWorkspaceSchema.optional(),
repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(),
repositoryUrls: z.array(z.string().url().max(2000)).max(100).optional(),
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
import { heartbeatService } from "../services/heartbeat.js";
import { instanceSettingsService } from "../services/instance-settings.js";
@@ -120,9 +120,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
const f = await seed();
const storage = await createPluginWorkspaceFixture(db, f.companyId);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
} } }).where(eq(plugins.id, storage.pluginId));
const [environment] = await db.insert(environments).values({ name: `Storage tasks ${storage.pluginId}`, driver: "plugin", status: "active",
config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson,
capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"],
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
} }).where(eq(plugins.id, storage.pluginId));
await db.update(agents).set({ defaultEnvironmentId: environment.id }).where(eq(agents.id, f.agentId));
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId,
executionWorkspaceSettings: settings,
@@ -132,6 +137,9 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
const createOrchestrator = environmentOrchestrator.environmentRunOrchestrator;
const realization = vi.spyOn(environmentOrchestrator, "environmentRunOrchestrator").mockImplementation((...args) => ({
...createOrchestrator(...args),
// These tests dispatch a synthetic adapter; substitute only the provider
// transport while preserving the configured storage/task-driver identity.
acquireForRun: async input => createOrchestrator(...args).acquireForRun({ ...input, selectedEnvironmentId: input.localEnvironmentId }),
realizeForRun: async input => {
input.executionWorkspace.cwd = cwd;
return { lease: input.lease, persistedExecutionWorkspace: input.persistedExecutionWorkspace,
@@ -1300,6 +1300,17 @@ describe("openapi routes", () => {
expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" });
});
it("documents project environment compatibility and creation selection", () => {
const { spec } = loadSpecRoutes();
const operation = spec.paths["/api/companies/{companyId}/project-environments"].get;
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
expect(operation.responses["403"]).toBeDefined();
expect(operation.responses["200"].content["application/json"].schema.properties).toHaveProperty("defaultEnvironmentId");
const creation = spec.paths["/api/companies/{companyId}/projects"].post;
expect(creation.requestBody.content["application/json"].schema.properties.environmentId).toMatchObject({ type: "string", format: "uuid" });
expect(creation.requestBody.content["application/json"].schema.properties.expectedStorageProviderPluginId).toMatchObject({ type: "string", nullable: true });
});
it("documents board-only repository discovery and selection", () => {
const { spec } = loadSpecRoutes();
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
@@ -0,0 +1,220 @@
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 { 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";
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { applyDefaultIsolatedExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType } from "../services/execution-workspace-policy.js";
import { projectService } from "../services/projects.js";
import { preparePluginWorkspace } from "../services/plugin-workspaces.js";
import { projectRoutes } from "../routes/projects.js";
import { errorHandler } from "../middleware/index.js";
import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("project environment compatibility", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("project-environments");
db = createDb(database.connectionString);
}, 120_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
beforeEach(async () => { await db.execute(sql`truncate companies, plugins, environments, instance_settings cascade`); });
async function fixture() {
const f = await createPluginWorkspaceFixture(db);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
plugin.manifestJson = { ...plugin.manifestJson,
capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"],
environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, displayName: "Computers", configSchema: { type: "object", properties: {} } }],
workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
};
await db.update(plugins).set({ manifestJson: plugin.manifestJson }).where(eq(plugins.id, plugin.id));
const [computer, local, sandbox, ssh] = await db.insert(environments).values([
{ name: "Computers", driver: "plugin", config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } },
{ name: "Local", driver: "local" },
{ name: "Sandbox", driver: "sandbox", config: { provider: "daytona" } },
{ name: "Build host", driver: "ssh", config: { host: "build.example.com" } },
]).returning();
await db.insert(instanceSettings).values({ defaultEnvironmentId: sandbox.id });
return { ...f, plugin, computer, local, sandbox, ssh };
}
it("groups by repository storage, across driver types and provider configurations", async () => {
const f = await fixture();
const [otherComputer] = await db.insert(environments).values({ name: "Other computers", driver: "plugin",
config: { ...f.computer.config, driverConfig: { profile: "other" } } }).returning();
const [otherSandbox] = await db.insert(environments).values({ name: "Other sandbox", driver: "sandbox", config: { provider: "modal" } }).returning();
const options = await projectEnvironmentOptions(db, f.companyId);
expect(options.error).toBeNull();
expect(options.defaultEnvironmentId).toBe(f.sandbox.id);
expect(options.groups).toHaveLength(2);
expect(options.groups.find(group => group.storageProviderPluginId === null)?.environments.map(value => value.id).sort())
.toEqual([f.local.id, f.sandbox.id, f.ssh.id, otherSandbox.id].sort());
expect(options.groups.find(group => group.storageProviderPluginId === f.pluginId)?.environments.map(value => value.id).sort())
.toEqual([f.computer.id, otherComputer.id].sort());
expect(JSON.stringify(options)).not.toContain("driverConfig");
expect(JSON.stringify(options)).not.toContain("build.example.com");
});
it("does not equate two plugin drivers with different storage owners", async () => {
const f = await fixture();
const other = await createPluginWorkspaceFixture(db, f.companyId);
await db.update(plugins).set({ manifestJson: { ...f.plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId));
await db.insert(environments).values({ name: "Separate computers", driver: "plugin", config: { pluginKey: `test.${other.pluginId}`, driverKey: "tasks" } });
expect((await projectEnvironmentOptions(db, f.companyId)).groups).toHaveLength(3);
expect(projectStorageForEnvironment({ driver: "plugin", config: { pluginKey: f.plugin.pluginKey, driverKey: "unknown" } }, [f.plugin])).toBeNull();
expect(projectStorageForEnvironment({ driver: "sandbox", config: { provider: "fake" } }, [f.plugin])).toBeNull();
});
it("uses the creating agent, the user's primary agent, then the instance default", async () => {
const f = await fixture();
await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId));
await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId: "person", primaryAgentId: f.input.agentId });
expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.sandbox.id);
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.computer.id);
expect((await projectEnvironmentOptions(db, f.companyId, { agentId: f.input.agentId })).defaultEnvironmentId).toBe(f.computer.id);
expect(await withDefaultProjectStorage(db, f.companyId, null)).toBeNull();
expect(await withDefaultProjectStorage(db, f.companyId, null, { userId: "person" })).toMatchObject({ storageProviderPluginId: f.pluginId });
await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.input.agentId));
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.sandbox.id);
await db.update(instanceSettings).set({ defaultEnvironmentId: null });
expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.local.id);
});
it("pins the selected storage, preserves explicit API choices, and rejects conflicting choices", async () => {
const f = await fixture();
const svc = projectService(db);
const sandboxProject = await svc.createWithRepositories(f.companyId, { name: "Sandbox project" }, [
{ id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] },
]);
expect(sandboxProject.workspaceProvider?.pluginId).toBeNull();
const computerProject = await svc.create(f.companyId, { name: "Computer project" }, { environmentId: f.computer.id });
expect(computerProject.workspaceProvider?.pluginId).toBe(f.pluginId);
const explicit = { enabled: true, storageProviderPluginId: null };
expect(await withDefaultProjectStorage(db, f.companyId, explicit)).toEqual(explicit);
await expect(withDefaultProjectStorage(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
expect((await svc.getById(sandboxProject.id))?.workspaceProvider?.pluginId).toBeNull();
});
it("preserves inherited task isolation when checking the observed core storage owner", async () => {
const f = await fixture();
const { experimental: settings } = await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true });
const project = await projectService(db).createWithRepositories(f.companyId, { name: "Inherited isolation" }, [
{ id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] },
], { environmentId: f.sandbox.id, expectedStorageProviderPluginId: null });
expect(project.executionWorkspacePolicy).toBeNull();
const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(project.executionWorkspacePolicy), settings.enableIsolatedWorkspaces),
defaultIsolatedWorkspacesEnabled: settings.enableIsolatedWorkspacesByDefault,
hasProjectWorkspace: project.workspaces.length > 0,
});
const mode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings: null, legacyUseProjectWorkspace: null });
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, issueSettings: null, mode, legacyUseProjectWorkspace: null });
expect(mode).toBe("isolated_workspace");
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree");
});
it("fails before a provider operation when managed storage cannot serve the selected environment", async () => {
const f = await fixture();
expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.sandbox, driver: "sandbox" })).toThrow('does not support the environment "Sandbox"');
await expect(preparePluginWorkspace(db, f.workers, { ...f.input, environment: { ...f.sandbox, driver: "sandbox" } }))
.rejects.toMatchObject({ status: 409, details: { code: "project_environment_incompatible", retryable: false } });
expect(f.calls).toHaveLength(0);
expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.computer, driver: "plugin" })).not.toThrow();
});
it("excludes archived, company-inaccessible, and disabled plugin environments", async () => {
const f = await fixture();
const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning();
await db.insert(builtInManagedResources).values({ companyId: otherCompany.id, bundleKey: "test", resourceKind: "environment",
resourceKey: "environment", resourceId: f.ssh.id, stockVersion: "1", stockHash: "test" });
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.local.id));
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, f.pluginId));
const options = await projectEnvironmentOptions(db, f.companyId);
expect(options.groups.flatMap(group => group.environments).map(value => value.id)).toEqual([f.sandbox.id]);
for (const environmentId of [f.local.id, f.ssh.id, f.computer.id, randomUUID()]) {
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
}
});
it("keeps a cold worker's storage identity, but refuses an archived default", 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();
await expect(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 });
});
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();
const now = new Date();
await db.insert(authUsers).values({ id: userId, name: "Person", email: `${userId}@example.com`, createdAt: now, updatedAt: now });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId,
status: "active", membershipRole: "operator" });
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id));
if (source === "instance") await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
else {
await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId));
await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId, primaryAgentId: f.input.agentId });
}
const task = await issueService(db).create(f.companyId, { title: "Private note", visibility: "private", createdByUserId: userId });
expect(task.visibility).toBe("private");
const project = await projectService(db).getById(task.projectId!);
expect(project?.name).toBe("My private tasks");
expect(project?.executionWorkspacePolicy).toBeNull();
expect(project?.workspaces).toEqual([]);
});
it("honors managed-only and forced Kubernetes execution policy", async () => {
const f = await fixture();
await db.update(environments).set({ metadata: { managedByPaperclip: true, managedKubernetesSandbox: true } }).where(eq(environments.id, f.sandbox.id));
await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id, experimental: { enableManagedSandboxOnly: true } });
const managed = await projectEnvironmentOptions(db, f.companyId);
expect(managed.defaultEnvironmentId).toBe(f.sandbox.id);
expect(managed.groups.flatMap(group => group.environments).map(value => value.id)).not.toContain(f.local.id);
await db.update(instanceSettings).set({ general: { executionMode: "kubernetes" } });
const forced = await projectEnvironmentOptions(db, f.companyId);
expect(forced.groups).toEqual([{ storageProviderPluginId: null, environments: [{ id: f.sandbox.id, name: f.sandbox.name }] }]);
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
});
it("exposes safe groups and saves a validated choice through the normal API", async () => {
const f = await fixture();
function app(actor: Express.Request["actor"]) {
const result = express(); result.use(express.json());
result.use((req, _res, next) => { req.actor = actor; next(); });
result.use("/api", projectRoutes(db)); result.use(errorHandler); return result;
}
const board = app({ type: "board", userId: "person", source: "session", companyIds: [f.companyId] });
const url = `/api/companies/${f.companyId}`;
const options = await request(board).get(`${url}/project-environments`);
expect(options.status).toBe(200);
expect(options.body.groups).toHaveLength(2);
const created = await request(board).post(`${url}/projects`).send({ name: "Selected", environmentId: f.computer.id });
expect(created.status).toBe(201);
expect(created.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
const denied = app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() });
expect((await request(denied).get(`${url}/project-environments`)).status).toBe(403);
expect((await request(app({ type: "none" })).get(`${url}/project-environments`)).status).toBe(401);
const agent = app({ type: "agent", agentId: f.input.agentId, companyId: f.companyId });
expect((await request(agent).get(`${url}/project-environments`)).status).toBe(200);
await db.update(environments).set({ driver: "plugin", config: f.computer.config }).where(eq(environments.id, f.ssh.id));
const changed = await request(board).post(`${url}/projects`).send({ name: "Changed storage", environmentId: f.ssh.id,
expectedStorageProviderPluginId: null });
expect(changed.status).toBe(409);
expect(changed.body.details.code).toBe("project_environment_incompatible");
const invalid = await request(board).post(`${url}/projects`).send({ name: "Invalid", environmentId: randomUUID() });
expect(invalid.status).toBe(409);
});
});
@@ -208,6 +208,7 @@ describe("project env routes", () => {
expect(mockProjectService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ env: normalizedEnv }),
expect.objectContaining({ userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@@ -36,13 +36,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
return f;
}
it("attaches a new project to the installed default and leaves existing projects where they are", async () => {
it("keeps a new project on core storage when no environment selects the installed plugin", async () => {
const f = await fixture();
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
const svc = projectService(db);
const created = await svc.create(f.companyId, { name: "New project" });
expect(created.executionWorkspacePolicy).toMatchObject({ enabled: true, storageProviderPluginId: f.pluginId });
expect(created.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
expect(created.executionWorkspacePolicy).toBeNull();
expect(created.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
const existing = await svc.getById(f.input.projectId);
expect(existing!.executionWorkspacePolicy).toBeNull();
expect(existing!.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
@@ -73,10 +73,10 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
.toMatchObject({ source: "project", pluginId: other.pluginId });
});
it("keeps a personal project on the installed default", async () => {
it("does not attach a personal project based only on a plugin default claim", async () => {
const f = await fixture();
const project = await ensurePersonalPrivateProject(db, f.companyId, "user-one");
expect(project.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
expect(project.executionWorkspacePolicy).toBeNull();
});
it.each(["create", "import"] as const)("%s leaves an unspecified task mode inherited from the effective provider", async method => {
@@ -153,7 +153,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
await db.insert(pluginCompanySettings).values({ companyId: other.id, pluginId: f.pluginId, enabled: true });
const svc = projectService(db);
expect((await svc.create(f.companyId, { name: "Disabled here" })).executionWorkspacePolicy).toBeNull();
expect((await svc.create(other.id, { name: "Enabled here" })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
expect((await svc.create(other.id, { name: "Enabled here", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true))
.rejects.toThrow("unavailable");
});
@@ -175,13 +175,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
});
it("asks for a choice when creating a project with conflicting defaults, and leaves existing projects working", async () => {
it("ignores conflicting storage default claims without a matching environment and preserves explicit choices", async () => {
const f = await fixture();
const other = await createPluginWorkspaceFixture(db, f.companyId);
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId));
const svc = projectService(db);
await expect(svc.create(f.companyId, { name: "Ambiguous" })).rejects.toThrow("Multiple workspace storage plugins");
expect((await svc.create(f.companyId, { name: "Core" })).workspaceProvider).toMatchObject({ pluginId: null, error: null });
expect((await svc.create(f.companyId, { name: "Chosen", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).workspaceProvider)
.toMatchObject({ pluginId: f.pluginId, error: null });
expect((await svc.getById(f.input.projectId))!.workspaceProvider).toMatchObject({ pluginId: f.pluginId, error: null });
+20
View File
@@ -5126,6 +5126,26 @@ registry.registerPath({
// ─── Projects ────────────────────────────────────────────────────────────────
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/project-environments",
tags: ["projects"],
summary: "List compatible project environments and the caller's default",
description: "Groups configured environments by repository storage owner. Returns names and IDs without provider configuration. Creation revalidates environmentId and stores its storage owner without overriding agent execution environments.",
request: { params: z.object({ companyId: z.string() }) },
responses: {
200: r.ok(z.object({
groups: z.array(z.object({
storageProviderPluginId: z.string().uuid().nullable(),
environments: z.array(z.object({ id: z.string().uuid(), name: z.string() })),
})),
defaultEnvironmentId: z.string().uuid().nullable(),
error: z.string().nullable(),
})),
401: r.unauthorized, 403: r.forbidden,
},
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/project-repositories",
+21 -3
View File
@@ -1,3 +1,4 @@
import { projectEnvironmentOptions } from "../services/project-environments.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { skillAccessRequestContext } from "../services/skill-access.js";
import { createHash } from "node:crypto";
@@ -375,15 +376,26 @@ export function projectRoutes(db: Db) {
res.json(summary);
});
router.get("/companies/:companyId/project-environments", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await projectEnvironmentOptions(db, companyId, {
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
}));
});
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
type CreateProjectPayload = Parameters<typeof svc.create>[1] & {
workspace?: Parameters<typeof svc.createWorkspace>[1];
repositoryIds?: string[];
environmentId?: string;
expectedStorageProviderPluginId?: string | null;
};
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, environmentId, expectedStorageProviderPluginId, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
const receiptOnly = req.get("X-Paperclip-Receipt-Only") === "true";
if (receiptOnly && !idempotencyKey) throw unprocessable("Project receipt recovery requires an idempotency key");
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
@@ -412,7 +424,7 @@ export function projectRoutes(db: Db) {
const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl);
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
const actor = getActorInfo(req);
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex");
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls, environmentId, expectedStorageProviderPluginId })).digest("hex");
const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null;
const result = await db.transaction(async (tx) => {
if (receiptKey) {
@@ -457,7 +469,13 @@ export function projectRoutes(db: Db) {
if (receiptOnly) return null;
if (runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
const service = projectService(tx as unknown as Db);
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
const environment = {
environmentId,
expectedStorageProviderPluginId,
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
};
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories, environment) : await service.create(companyId, projectData, environment);
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db);
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
const registeredUrls: typeof urlRepositories = [];
@@ -265,7 +265,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
driver: "sandbox" as const, config: { provider: "daytona" } };
await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, input.agent.id));
input.agent.adapterType = "paperclip_runner";
input.environment.selectedEnvironmentForConfig = environment;
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
input.environment.selectedEnvironmentId = environment.id;
expect(input.environment.resolvedInstanceSettings.experimental.remoteWorkspaceStorageEnabled).toBe(true);
input.policy.requestedExecutionWorkspaceMode = "agent_default";
@@ -964,7 +964,13 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
const pluginKey = `test.managed-storage-${fromDefault}-${defaultMode}-${fresh}`;
const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"],
manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"],
workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe", "environment.drivers.register"],
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
const [environment] = await db.insert(environments).values({ name: `Storage tasks ${pluginKey}`, driver: "plugin", status: "active",
config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
input.environment.selectedEnvironmentId = environment.id;
const savedPolicy = fromDefault ? { enabled: true, storageProviderPluginId: plugin.id } : { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const,
workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } };
await db.update(projects).set({ executionWorkspacePolicy: savedPolicy }).where(eq(projects.id, issue.projectId!));
@@ -989,7 +995,8 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
const acquire = input.services.envOrchestrator.acquireForRun;
vi.spyOn(input.services.envOrchestrator, "acquireForRun").mockImplementation(async args => {
sequence.push("acquire"); expect(args.persistedExecutionWorkspace).toMatchObject({ providerType: "plugin", cwd: null });
return acquire(args);
// Acquisition is a transport stub: this boundary test stops at handoff.
return acquire({ ...args, selectedEnvironmentId: args.localEnvironmentId });
});
vi.spyOn(input.services.envOrchestrator, "realizeForRun").mockImplementation(async args => {
expect(args.executionWorkspace.providerBinding?.workspaceRef).toBe("provider:checkout");
@@ -1090,7 +1097,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
environmentDrivers: [{ driverKey: "tasks", displayName: "Task VM", configSchema: { type: "object" }, supportsTasks: true, freshWorkspaceVersion: 1 }] } });
const environment = await environmentService(db).create({ name: `Task computer ${issue.id}`, driver: "plugin", status: "active", envVars: {}, config: { pluginKey, driverKey: "tasks" } });
input.agent = { ...input.agent, adapterType: "paperclip_runner", defaultEnvironmentId: environment.id };
input.environment.selectedEnvironmentForConfig = environment;
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
input.policy.requestedExecutionWorkspaceMode = input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace";
input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] };
const stopped = new Error("Provider directory prepared");
@@ -554,6 +554,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
existingWorkspaceId: providerFresh ? providerFresh.previousWorkspaceId : reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
...(providerFresh ? { fresh: providerFresh, expectedBindingRevision: previousProviderBindingRevision } : {}),
selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
environment: selectedEnvironmentForConfig,
mode: requestedExecutionWorkspaceMode,
strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig),
branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create",
+3 -1
View File
@@ -1,5 +1,5 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
import { assertProjectStorageEnvironment, effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js";
import { retireAbandonedPluginWorkspaceReservation } from "./plugin-workspace-reservations.js";
import { randomUUID, createHash } from "node:crypto";
@@ -198,6 +198,7 @@ export async function preparePluginWorkspace(
mode: string;
strategy: string;
selectedProviderPluginId?: string | null;
environment?: Pick<import("@paperclipai/shared").Environment, "driver" | "config" | "name"> | null;
branchMode?: "create" | "existing";
branchName?: string | null;
baseRef?: string | null;
@@ -276,6 +277,7 @@ export async function preparePluginWorkspace(
"An existing local workspace cannot change storage providers",
);
const selectedProvider = await provider(db, workers, input.companyId, pluginId, true);
if (input.environment) assertProjectStorageEnvironment(selectedProvider, input.environment);
const declaration = selectedProvider.manifestJson.workspaceProvider!;
if (input.fresh && declaration.freshWorkspaceVersion !== 1)
throw conflict("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", { reason: "fresh_workspace_storage_unsupported" });
@@ -0,0 +1,91 @@
import { and, eq, notInArray } from "drizzle-orm";
import { agents, builtInManagedResources, userCompanyPreferences, type Db } from "@paperclipai/db";
import type { ProjectEnvironmentOptions } from "@paperclipai/shared";
import { conflict } from "../errors.js";
import { environmentService } from "./environments.js";
import { instanceSettingsService } from "./instance-settings.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { listProjectWorkspaceProviders, projectStorageForEnvironment } from "./project-workspace-provider.js";
export interface ProjectCreationEnvironment {
userId?: string | null;
agentId?: string | null;
environmentId?: string;
expectedStorageProviderPluginId?: string | null;
}
export async function projectEnvironmentOptions(db: Db, companyId: string, actor: ProjectCreationEnvironment = {}): Promise<ProjectEnvironmentOptions> {
const svc = environmentService(db);
const [settings, environments, providers, bindings] = await Promise.all([
instanceSettingsService(db).get(),
svc.list({ status: "active" }),
listProjectWorkspaceProviders(db, companyId),
db.select({ environmentId: builtInManagedResources.resourceId, companyId: builtInManagedResources.companyId })
.from(builtInManagedResources).where(eq(builtInManagedResources.resourceKind, "environment")),
]);
let preferredEnvironmentId = settings.defaultEnvironmentId;
if (actor.agentId || actor.userId) {
const candidates = db.select({ environmentId: agents.defaultEnvironmentId }).from(agents);
const [agent] = actor.agentId
? await candidates.where(and(eq(agents.companyId, companyId), eq(agents.id, actor.agentId)))
: await candidates.innerJoin(userCompanyPreferences, and(eq(userCompanyPreferences.primaryAgentId, agents.id),
eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.userId, actor.userId!)))
.where(and(eq(agents.companyId, companyId), notInArray(agents.status, ["terminated", "pending_approval"])));
preferredEnvironmentId = agent?.environmentId ?? preferredEnvironmentId;
}
let preferred = environments.find(environment => environment.id === preferredEnvironmentId)
?? (!preferredEnvironmentId ? environments.find(environment => environment.driver === "local") : undefined);
const forcedKubernetes = settings.general.executionMode === "kubernetes";
const managedOnly = settings.experimental.enableManagedSandboxOnly === true;
if (forcedKubernetes) {
preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedKubernetesSandbox === true);
} else if (managedOnly && (!preferred || preferred.driver === "local")) {
preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedByPaperclip === true);
}
const groups: ProjectEnvironmentOptions["groups"] = [];
for (const environment of environments) {
const owners = bindings.filter(binding => binding.environmentId === environment.id);
if (owners.length && !owners.some(binding => binding.companyId === companyId)) continue;
if (forcedKubernetes && environment.id !== preferred?.id) continue;
if (managedOnly && environment.driver === "local") continue;
const storage = projectStorageForEnvironment(environment, providers);
if (!storage) continue;
let group = groups.find(value => value.storageProviderPluginId === storage.pluginId);
if (!group) {
group = { storageProviderPluginId: storage.pluginId, environments: [] };
groups.push(group);
}
group.environments.push({ id: environment.id, name: environment.name });
}
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));
return {
groups,
defaultEnvironmentId: defaultAvailable ? preferred?.id ?? null : null,
error: !defaultAvailable && (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,
};
}
export async function withDefaultProjectStorage(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) {
const policy = parseProjectExecutionWorkspacePolicy(rawPolicy);
if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy;
const options = await projectEnvironmentOptions(db, companyId, actor);
const environmentId = actor.environmentId ?? options.defaultEnvironmentId;
const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId));
if (actor.environmentId && !group) throw conflict("The selected environment is unavailable for this project. Select an available environment.", { code: "project_environment_unavailable" });
if (!actor.environmentId && options.error) throw conflict(options.error, { code: "project_environment_unavailable" });
const pluginId = group?.storageProviderPluginId ?? null;
if (actor.expectedStorageProviderPluginId !== undefined && actor.expectedStorageProviderPluginId !== pluginId) {
throw conflict("The environment's project storage changed. Review the available environments and retry.", { code: "project_environment_incompatible" });
}
if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) {
throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" });
}
return pluginId || policy?.storageProviderPluginId !== undefined
? { enabled: true, ...policy, storageProviderPluginId: pluginId }
: rawPolicy;
}
@@ -1,6 +1,6 @@
import { and, eq, notInArray } from "drizzle-orm";
import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db";
import type { PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
import type { Environment, PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { conflict } from "../errors.js";
@@ -68,19 +68,33 @@ export function sourcelessProjectWorkspacePolicy(rawPolicy: unknown): ProjectExe
return Object.keys(settings).some(key => key !== "enabled") ? settings : null;
}
export async function withDefaultProjectStorage(db: ProviderDb, companyId: string, rawPolicy: unknown) {
if (parseProjectExecutionWorkspacePolicy(rawPolicy)?.storageProviderPluginId !== undefined) return rawPolicy;
const defaults = (await listProjectWorkspaceProviders(db, companyId))
.filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects);
if (defaults.length > 1) {
throw conflict(`Multiple workspace storage plugins claim the project default: ${defaults.map(plugin => plugin.manifestJson.displayName).sort().join(", ")}. Select a storage provider in project configuration or disable a conflicting provider.`);
}
if (!defaults[0]) return rawPolicy;
return { enabled: true, ...(rawPolicy as Record<string, unknown> | null), storageProviderPluginId: defaults[0].id };
}
export async function effectiveProjectWorkspacePolicy(db: ProviderDb, companyId: string, rawPolicy: unknown, hasGitSource: boolean) {
const configuration = resolveProjectWorkspaceProvider(rawPolicy, await listProjectWorkspaceProviders(db, companyId), hasGitSource);
if (configuration.error) throw conflict(configuration.error);
return configuration.effectivePolicy;
}
export function projectStorageForEnvironment(
environment: Pick<Environment, "driver" | "config">,
providers: Array<Pick<Provider, "id" | "pluginKey" | "manifestJson">>,
): { pluginId: string | null } | null {
if (["local", "ssh", "sandbox"].includes(environment.driver)) {
return environment.driver === "sandbox" && environment.config.provider === "fake" ? null : { pluginId: null };
}
if (environment.driver !== "plugin") return null;
const plugin = providers.find(value => value.pluginKey === environment.config.pluginKey);
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === environment.config.driverKey
&& value.kind !== "sandbox_provider" && value.supportsTasks === true);
return plugin && driver && plugin.manifestJson.capabilities.includes("environment.drivers.register")
? { pluginId: plugin.id } : null;
}
export function assertProjectStorageEnvironment(
provider: Pick<Provider, "id" | "pluginKey" | "manifestJson">,
environment: Pick<Environment, "driver" | "config" | "name">,
) {
if (projectStorageForEnvironment(environment, [provider])?.pluginId !== provider.id) {
throw conflict(`This project's workspace storage (${provider.manifestJson.displayName}) does not support the environment "${environment.name}". Use an agent in a compatible environment, or create a project for this environment.`,
{ code: "project_environment_incompatible", retryable: false });
}
}
+6 -5
View File
@@ -1,6 +1,7 @@
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, withDefaultProjectStorage } from "./project-workspace-provider.js";
import { withDefaultProjectStorage, type ProjectCreationEnvironment } from "./project-environments.js";
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js";
@@ -115,7 +116,6 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin
visibility: "private",
personalOwnerUserId: userId,
privacyOwnerUserId: userId,
executionWorkspacePolicy: await withDefaultProjectStorage(dbOrTx, companyId, null) as Record<string, unknown> | null,
})
.onConflictDoNothing()
.returning()
@@ -654,6 +654,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const createProject = async (
companyId: string,
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
environment: ProjectCreationEnvironment = {},
): Promise<ProjectWithGoals> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
@@ -674,7 +675,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
// skipped validation whenever `goalIds: []` and `goalId` arrive
// together (goalIds wins resolution, mirroring the update path).
const legacyGoalId = ids?.[0] ?? null;
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null) as typeof projectData.executionWorkspacePolicy;
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null, environment) as typeof projectData.executionWorkspacePolicy;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
@@ -919,10 +920,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
};
},
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals> => {
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[], environment: ProjectCreationEnvironment = {}): Promise<ProjectWithGoals> => {
return db.transaction(async (tx) => {
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const project = await service.create(companyId, data);
const project = await service.create(companyId, data, environment);
for (const repo of repositories) {
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
@@ -264,7 +264,7 @@
"packages/shared/src/home-paths.ts": "fa4f1c440653b76b3dbb7a51026d557287deb45d",
"packages/shared/src/humanize-connection.test.ts": "ba9e90a44c63452b0bf9f733adb90241921e8eca",
"packages/shared/src/humanize-connection.ts": "fbd414476a0ef7106a7c80d8572208781b091699",
"packages/shared/src/index.ts": "7ce86fb2ce04fa07ee4e742af137af2dfd10a3f3",
"packages/shared/src/index.ts": "a4ec3e9cca1eb3954d247eabd184b103ccefc2a2",
"packages/shared/src/issue-activity-identity.test.ts": "3dc294640f54c2be67943e3e0149cf4eff2c95ca",
"packages/shared/src/issue-activity-identity.ts": "1b0f07ec3fe0506aaaf80a4fa9893f3baf8f0075",
"packages/shared/src/issue-attribution.test.ts": "84563685e7f3c48029243cf1cc24a3a09ee3cde9",
@@ -397,14 +397,14 @@
"packages/shared/src/types/inbox-agent-policy.ts": "cb9d41d9681e80b834889268ad96015a39df92f6",
"packages/shared/src/types/inbox-dismissal.ts": "5d2c71ce44ad77266d286ec1b1119ab1f0390a9d",
"packages/shared/src/types/inbox-mine.ts": "1d302b531d34842189704abd2f75f8743d09490d",
"packages/shared/src/types/index.ts": "0f9b69e2285e600dbcbcc4e37acafa9bfed65b5c",
"packages/shared/src/types/index.ts": "01845e2a041eb892c3a193685471d635da2c1be9",
"packages/shared/src/types/instance.ts": "2c40a18c05010991d93d719937faa4a43e47104f",
"packages/shared/src/types/issue-tree-control.ts": "75489fffd491b8350b05caf57d7796419797d9df",
"packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d",
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
"packages/shared/src/types/plugin.ts": "07c409d7daca3fc9ad550538d4a7ecac03176a07",
"packages/shared/src/types/project.ts": "43a5232bdd1843301477403568ce0c9bc5e26de5",
"packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe",
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",
"packages/shared/src/types/routine.ts": "e5e5a93d63233029b9bf258558ad155677a4eca7",
@@ -485,7 +485,7 @@
"packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53",
"packages/shared/src/validators/plugin.test.ts": "e145e15cb98ffe35880b9f8eea88809910ed5886",
"packages/shared/src/validators/plugin.ts": "318fac91061e0373f9acd7e9b29cb427dfdc4b7f",
"packages/shared/src/validators/project.ts": "61839f7a1b530f4d4e15df7546acf8b5f4a9843f",
"packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838",
"packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82",
"packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd",
"packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878",
+2
View File
@@ -2,6 +2,7 @@ import type {
Project,
ProjectAccessMember,
ProjectRepositoryOptions,
ProjectEnvironmentOptions,
ProjectGitAuthorization,
ProjectWorkspace,
WorkspaceOperation,
@@ -21,6 +22,7 @@ function projectPath(id: string, companyId?: string, suffix = "") {
}
export const projectsApi = {
environmentOptions: (companyId: string) => api.get<ProjectEnvironmentOptions>(`/companies/${companyId}/project-environments`),
gitAuthorization: (id: string) => api.get<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization")),
setGitAuthorization: (id: string, grantId: string | null) => api.put<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization"), { grantId }),
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
+110 -2
View File
@@ -6,10 +6,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PROJECT_COLORS } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { getLastProjectId } from "../lib/recent-projects";
import { NewProjectForm } from "./NewProjectDialog";
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } }));
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), environmentOptions: vi.fn() } }));
vi.mock("./ProjectRepositoryInput", () => ({
ProjectRepositoryInput: () => null,
repositoryOptionsKey: (companyId: string) => ["repositories", companyId],
@@ -25,7 +26,8 @@ let root: ReturnType<typeof createRoot>;
let container: HTMLDivElement;
beforeEach(() => {
localStorage.clear();
vi.mocked(projectsApi.create).mockReset();
vi.mocked(projectsApi.create).mockReset().mockResolvedValue({ id: "project-created" } as Awaited<ReturnType<typeof projectsApi.create>>);
vi.mocked(projectsApi.environmentOptions).mockReset().mockResolvedValue({ groups: [], defaultEnvironmentId: null, error: null });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -49,6 +51,7 @@ describe("new project colors", () => {
act(() => root.render(<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
<NewProjectForm companyId="company-1" onClose={onClose} />
</QueryClientProvider>));
await settle();
const previewColor = container.querySelector<HTMLElement>('span[style]')!.style.backgroundColor;
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
act(() => {
@@ -70,3 +73,108 @@ describe("new project colors", () => {
expect(onClose).toHaveBeenCalledOnce();
});
});
const groups = [
{ storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "ssh", name: "Build host" }] },
{ storageProviderPluginId: "computer-plugin", environments: [{ id: "computer", name: "Agent computers" }] },
];
async function renderForm() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
act(() => root.render(<QueryClientProvider client={client}>
<NewProjectForm companyId="company-1" onClose={vi.fn()} />
</QueryClientProvider>));
await settle();
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Environment project");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
return client;
}
describe("project environment selection", () => {
it("groups compatible environments and follows the user's default within a group", async () => {
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "ssh", error: null });
vi.mocked(projectsApi.create).mockResolvedValue({ id: "project" } as Awaited<ReturnType<typeof projectsApi.create>>);
await renderForm();
const select = container.querySelector<HTMLSelectElement>("select")!;
expect(select.options).toHaveLength(2);
expect(select.options[0].textContent).toBe("Paperclip Sandbox, Build host");
expect(select.value).toBe("sandbox");
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
await settle();
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" }));
});
it("sends an explicit choice and preserves it after a create failure", async () => {
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "computer", error: null });
vi.mocked(projectsApi.create).mockRejectedValue(new Error("Try again"));
await renderForm();
const select = container.querySelector<HTMLSelectElement>("select")!;
expect(select.value).toBe("computer");
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
await settle();
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "sandbox", expectedStorageProviderPluginId: null }));
expect(select.value).toBe("sandbox");
expect(vi.mocked(projectsApi.create).mock.calls[0][1].executionWorkspacePolicy).toBeUndefined();
});
it("hides the picker for compatible environments but still sends the default", async () => {
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups: [groups[0]], defaultEnvironmentId: "ssh", error: null });
await renderForm();
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" }));
});
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; }));
await renderForm();
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
expect(projectsApi.create).not.toHaveBeenCalled();
await act(async () => resolve({ groups, defaultEnvironmentId: "computer", error: null }));
await settle();
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
it("reports discovery failures and lets the user retry", async () => {
vi.mocked(projectsApi.environmentOptions).mockRejectedValueOnce(new Error("Environment lookup failed"))
.mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null });
await renderForm();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Environment lookup failed");
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!;
act(() => retry.click());
await settle();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
it("keeps cached environment choices during a transient refetch failure", async () => {
vi.mocked(projectsApi.environmentOptions).mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null })
.mockRejectedValue(new TypeError("Failed to fetch"));
const client = await renderForm();
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.projects.environments("company-1") }); });
await settle();
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("sandbox");
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
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();
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Default is unavailable");
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
const select = container.querySelector<HTMLSelectElement>("select")!;
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
});
+30 -5
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared";
import { X } from "lucide-react";
import { useDialog } from "../context/DialogContext";
@@ -10,6 +10,8 @@ import { queryKeys } from "../lib/queryKeys";
import { trackRecentProject } from "../lib/recent-projects";
import { ProjectTile } from "./ProjectTile";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { QueryErrorState, useQueryView } from "./QueryView";
import { NativeSelect } from "./ui/select";
import { Button } from "./ui/button";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
@@ -28,8 +30,16 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
const [repos, setRepos] = useState<ProjectRepository[]>([]);
const [connecting, setConnecting] = useState(false);
const input = useRef<HTMLInputElement>(null);
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(null);
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 environmentReady = Boolean(environmentView.kind !== "error" && options && (!options.error || selectedGroup) && (!options.groups.length || selectedGroup));
const create = useMutation({
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id), ...(selectedGroup ? { environmentId, expectedStorageProviderPluginId: selectedGroup.storageProviderPluginId } : {}) }),
onSuccess: (project) => {
trackRecentProject(project.id, companyId);
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
@@ -46,7 +56,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) });
setConnecting(false);
}} />
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}>
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && environmentReady && !create.isPending) create.mutate(); }}>
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
@@ -59,12 +69,27 @@ 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">
<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)}>
{!selectedGroup && <option value="" disabled>Select environments</option>}
{options.groups.map(group => <option key={group.environments[0].id} value={group.environments[0].id}>
{group.environments.map(environment => environment.name).join(", ")}
</option>)}
</NativeSelect>
<p className="text-xs text-muted-foreground">These environments can share this project's working files. The storage choice stays with the project.</p>
</div>}
{(environmentView.kind === "loading" || environmentView.kind === "reconnecting") && <p role="status" className="mb-3 text-sm text-muted-foreground">Loading environments…</p>}
{environmentView.kind === "error" && <QueryErrorState error={environmentView.error} size="inline" action="load project environments"
className="mb-3" onRetry={environmentView.retry} retrying={environmentView.isFetching} />}
{options?.error && !selectedGroup && <p role="alert" className="mb-3 text-sm text-destructive">{options.error}</p>}
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
</div>
{create.status === "error" && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{describeError(create.error).body}</p>}
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
<div className="flex shrink-0 justify-between gap-2 px-5 py-5">
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
<Button type="submit" disabled={!name.trim() || !environmentReady || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
</div>
</form>}
</DialogContent>
+1
View File
@@ -517,6 +517,7 @@ export const queryKeys = {
["environment-custom-image-setup-sessions", sessionId] as const,
},
projects: {
environments: (companyId: string) => ["project-environments", companyId] as const,
all: (companyId: string) => ["projects", companyId] as const,
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
[
@@ -335,6 +335,13 @@ function hydrateDialogQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents);
queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects);
queryClient.setQueryData(queryKeys.projects.environments(COMPANY_ID), {
groups: [
{ storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "local", name: "Local" }] },
{ storageProviderPluginId: "computer-storage", environments: [{ id: "computers", name: "Agent computers" }] },
],
defaultEnvironmentId: "sandbox", error: null,
});
queryClient.setQueryData(queryKeys.goals.list(COMPANY_ID), storybookGoals);
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
queryClient.setQueryData(queryKeys.issues.labels(COMPANY_ID), storybookIssueLabels);