mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
fix(projects): make workspace storage the project's only placement choice (#20835)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A repository project gets its working files from core staging or from a workspace storage plugin > - #20459 reported that an installed storage plugin attached every new repository project, so sandbox agents could not use those projects > - #20587 fixed creation with an **Available in** choice that selects storage > - #20651 then saved an environment on every new project and used it before the agent default to place runs. That solved a problem that did not exist: the project needs storage, not a run environment > - Creation saves the first environment of the chosen group, so a project silently sends every agent's runs to that environment. Project Configuration also shows two controls (Environment and Workspace storage) for one decision > - This pull request removes the project run environment and makes storage the only placement choice a project owns. Storage is fixed at creation > - The benefit is that runs follow the agent again, and project settings show one clear, read-only fact about storage ## Linked Issues or Issue Description Refs #20459. Follow-up to #20587 and #20651. Refs #11644. That issue asks for a project-level run environment override. This pull request removes the override that #20651 added for it, because the override is not necessary to fix #20459. #11644 stays a separate feature request. Related: #18176 (open) repairs placement through the legacy workspace-policy field. This pull request does not restore project placement, so it conflicts with the direction of #18176. ## What Changed - **Remove the project run environment.** Migration `0466` drops `projects.environment_id`. Run placement no longer reads the project. Runs use the agent default, then the instance default, then local execution. The low-trust task sandbox and the enforced sandbox policies keep their precedence. - **Keep the #20651 recovery and binding guards.** A recovered run still uses its admitted environment lease (source `run`). Company-binding checks before admission and during lease creation stay. - **Keep the creation choice.** **Available in** in New Project still selects storage. It still shows only when the environments need more than one storage owner. That can only occur when a storage plugin is installed. `POST /projects` with `environmentId` selects the storage of that environment and does not save the environment. - **Fix storage at creation.** `PATCH /projects/:id` rejects any storage change with `409 project_storage_locked`, from core to a plugin and from a plugin to core. Before, a core project could move to a plugin. - **Drop legacy writes.** Older clients can still send `executionWorkspacePolicy.environmentId`. The service removes it on create and update. - **Simplify Project Configuration.** Remove the Environment and Workspace storage selectors. Show an advisory **Available in** list of the environments that can use the project's storage. Show it when a storage plugin is installed or when the project uses one. When the project's plugin is not installed, show **Storage plugin unavailable**. Without a storage plugin, show nothing. - **Issue workspace card.** Remove the "project default" environment label. - **Shared contract.** Remove `Project.environmentId`, the `environmentId` project field, and `expectedStorageProviderPluginId` from the update schema. The create schema keeps `environmentId` and `expectedStorageProviderPluginId` from #20587. - **Docs and maps.** Update `doc/project-environment-compatibility.md`, `docs/api/goals-and-projects.md`, and the project feature and agent-access maps. - **SDK source pins.** Refresh `tests/runner-e2e/source-sdk-public-inputs.json` for the two shared files. The shared file blobs return to their values before #20651. The refresh also corrects the `pnpm-lock.yaml` pin and `lockSha256`: `master` pins an older lockfile blob than the one it contains. ## Verification - Targeted server suites pass (227 tests): `project-environments`, `project-workspace-provider.integration`, `heartbeat/environment-selection`, `execution-workspace-policy`, `environment-selection-route-guards`, `environment-run-orchestrator`, `openapi-routes`. - Related server suites pass (349 tests): `environment-service`, `environment-routes`, `plugin-workspaces.integration`, `issues-service`. - Targeted UI suites pass: `ProjectProperties.concurrency`, `IssueWorkspaceCard`, `NewProjectDialog`, `NewProjectDialog.managed-sandbox`, `ProjectDetail`. - New tests: - Creation saves storage, not an environment. Core and plugin projects both reject a storage change with `project_storage_locked`. - A legacy policy `environmentId` is dropped on create and update. - A new run follows the agent default for any project. A recovered run keeps its admitted lease after the agent default changes. - Project Configuration has no storage or environment selector. The note is hidden without a plugin. It lists the core or plugin environments for the project's storage. It shows **Storage plugin unavailable** for a removed plugin. - `pnpm --filter @paperclipai/server typecheck`, `pnpm --filter @paperclipai/ui typecheck`, and `pnpm --filter @paperclipai/shared typecheck` pass. - `pnpm --filter @paperclipai/db check:migrations`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` pass. - Not run locally: the full `pnpm test:run`, `pnpm build`, and browser suites. CI supplies that coverage. - Manual check: open Project Configuration on an instance with a storage plugin. Confirm that there is no Environment or Workspace storage selector, and that **Available in** lists the environments. ## Risks - **Migration.** `0466` drops `projects.environment_id` and its foreign key. The migration deletes the saved project environments. Apply it before you serve the new revision. An old server revision that reads `projects` fails until it stops. This is the same pattern as earlier drop-column migrations (`0229`, `0289`). - **Behavior change for existing projects.** Projects created after #20651 shipped have a saved environment that overrides the agent. After this change, their runs follow the agent default again. This is the intended fix. Before #20651, a project environment selection was not applied to runs (#11644), so no older behavior is lost. - **No path from core to a plugin.** A core project, including one created before a plugin was installed, cannot move to the plugin. To use plugin storage, create a new project. - **API.** A `PATCH` that sends `environmentId` or `expectedStorageProviderPluginId` no longer has an effect. The schema strips these fields. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, in Claude Code, with tool use (shell, file edits, test runs). Extended thinking was available. ## 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
39442400b7
commit
369e238295
28 files changed
+295
-496
No files matched your search
@@ -2,9 +2,9 @@
|
||||
|
||||
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 saves both the storage owner and the project's
|
||||
`environmentId`. This is a project-owned execution target, not a company or agent
|
||||
setting. Compatible environments can share its repository storage.
|
||||
all environments that use that owner. The choice selects storage only. It is not
|
||||
an allowlist, and it does not override an agent's run environment. A project has
|
||||
no run environment of its own.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
@@ -48,10 +48,9 @@ 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 and environment
|
||||
on that project. Creation saves the effective default once; changing the instance
|
||||
or primary agent later does not rewrite existing projects. Compatible environments
|
||||
share repository storage; the saved environment is the run target. An explicit `executionWorkspacePolicy.storageProviderPluginId`
|
||||
The server validates the choice again and stores only its storage owner. The
|
||||
project is then available to the compatible environments in that group; no single
|
||||
run environment is saved. 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
|
||||
@@ -73,25 +72,18 @@ workspace provider operation, with a message naming the storage and environment.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
`PATCH /api/projects/:id` accepts `environmentId` and the optional
|
||||
`expectedStorageProviderPluginId` precondition. The Environment selector reads
|
||||
and writes this project field, independently of checkout/isolation settings.
|
||||
Changing one project never changes another project, the agent, or instance settings.
|
||||
Only environments compatible with the project's current storage are offered.
|
||||
Changing storage ownership is subject to the existing retained-data safeguards.
|
||||
Storage is fixed when the project is created. `PATCH /api/projects/:id` rejects a
|
||||
change of `executionWorkspacePolicy.storageProviderPluginId` with `409`
|
||||
`project_storage_locked`, in both directions: core to plugin and plugin to core.
|
||||
A policy update that omits the storage owner keeps the stored owner.
|
||||
|
||||
Ordinary run selection uses the project's saved target before the agent default,
|
||||
instance default, and local fallback. A null project target explicitly inherits
|
||||
those defaults. An operator-selected low-trust task sandbox and enforced sandbox
|
||||
policies retain precedence. Provider API execution keeps its existing placement.
|
||||
Project Configuration has no storage or environment control. When a storage plugin
|
||||
is installed, or the project uses one, it shows an advisory **Available in** list:
|
||||
the environments that can use the project's storage. If the project's storage
|
||||
plugin is no longer installed, it shows **Storage plugin unavailable**. Without a
|
||||
storage plugin, the advisory is hidden.
|
||||
|
||||
The migration preserves existing valid project-policy environment selections in
|
||||
the new project field. Older clients may still write the policy field; the service
|
||||
normalizes it into the project association. Existing projects with no selection
|
||||
remain unset. Environment deletion counts and clears only matching associations.
|
||||
|
||||
Changing or clearing placement requires project workspace-write authority. A
|
||||
saved selection is checked against company bindings before runtime admission and
|
||||
again during lease creation. Recovery uses the admitted run's owned environment
|
||||
lease; a later project edit affects new admissions. The task's organizational
|
||||
project owns placement even when its files come from another project.
|
||||
Runs use the agent default, then the instance default, then local execution. An
|
||||
operator-selected low-trust task sandbox and enforced sandbox policies retain
|
||||
precedence. A project does not change run placement. Older clients may still send
|
||||
`executionWorkspacePolicy.environmentId`; the service drops it.
|
||||
@@ -61,7 +61,7 @@ GET /api/companies/{companyId}/projects
|
||||
GET /api/projects/{projectId}
|
||||
```
|
||||
|
||||
Returns project details including workspaces and the project-owned `environmentId`.
|
||||
Returns project details including workspaces.
|
||||
|
||||
### Create Project
|
||||
|
||||
@@ -84,9 +84,10 @@ POST /api/companies/{companyId}/projects
|
||||
|
||||
Notes:
|
||||
|
||||
- `environmentId` optionally selects the project environment. When omitted, creation
|
||||
saves the creator's effective default. The saved choice does not change when the
|
||||
creator or instance default changes later.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -103,11 +104,9 @@ PATCH /api/projects/{projectId}
|
||||
}
|
||||
```
|
||||
|
||||
`environmentId` can be updated independently of workspace/isolation policy. Set it
|
||||
to an available compatible environment ID, or `null` to inherit agent/instance
|
||||
defaults. The update affects only this project. An incompatible or unavailable
|
||||
environment returns `409`; company access is required. Existing plugin-owned data
|
||||
cannot be moved to another storage owner through this field.
|
||||
Workspace storage is fixed at creation. A change of
|
||||
`executionWorkspacePolicy.storageProviderPluginId` returns `409`
|
||||
(`project_storage_locked`). Runs use the agent's environment, not a project setting.
|
||||
|
||||
## Project Workspaces
|
||||
|
||||
|
||||
@@ -151,6 +151,6 @@ Gaps: The dedicated create_project tool does not advertise a visibility field; e
|
||||
|
||||
Project configuration responses also expose effective workspace storage through
|
||||
`workspaceProvider`, separate from the stored execution policy. Existing project
|
||||
read/update permissions apply. Omitted provider IDs inherit; null explicitly selects
|
||||
Core/local. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts)
|
||||
read/update permissions apply. Omitted provider IDs keep the stored owner; null explicitly selects
|
||||
Core/local at creation. An update that changes the owner returns `409` `project_storage_locked`. [Offline API coverage](../../server/src/__tests__/project-workspace-provider.integration.test.ts)
|
||||
checks overrides and cross-company denial; live agent credential verification remains unqualified.
|
||||
@@ -29,8 +29,9 @@ 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 saved provider or Core/local choice. Plugin-backed
|
||||
projects retain their storage owner.
|
||||
Storage is fixed at creation and has no control here. When a storage plugin is
|
||||
installed, or the project uses one, **Available in** lists the environments that
|
||||
can use the project's storage.
|
||||
|
||||
### `project-access`
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "projects" DROP CONSTRAINT IF EXISTS "projects_environment_id_environments_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "projects" DROP COLUMN IF EXISTS "environment_id";
|
||||
+100
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "f409684f-c629-4648-9123-a5e1064308ef",
|
||||
"prevId": "f93ccf18-7e86-4788-be77-b17430c86b2f",
|
||||
"id": "593f81c4-2b4d-4fb4-a3e3-848b19bc9b0f",
|
||||
"prevId": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -6233,7 +6233,7 @@
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
"notNull": false
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
@@ -6309,11 +6309,11 @@
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_issuer_account_id_uq": {
|
||||
"name": "account_issuer_account_id_uq",
|
||||
"account_provider_account_id_uq": {
|
||||
"name": "account_provider_account_id_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "issuer",
|
||||
"expression": "provider_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
@@ -49107,6 +49107,93 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.plugin_lifecycle_resource_cursors": {
|
||||
"name": "plugin_lifecycle_resource_cursors",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"plugin_id": {
|
||||
"name": "plugin_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_type": {
|
||||
"name": "resource_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"resource_id": {
|
||||
"name": "resource_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"acknowledged_through_id": {
|
||||
"name": "acknowledged_through_id",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": {
|
||||
"name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk",
|
||||
"tableFrom": "plugin_lifecycle_resource_cursors",
|
||||
"tableTo": "plugins",
|
||||
"columnsFrom": [
|
||||
"plugin_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"plugin_lifecycle_resource_cursors_company_id_companies_id_fk": {
|
||||
"name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk",
|
||||
"tableFrom": "plugin_lifecycle_resource_cursors",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"plugin_lifecycle_resource_cursors_pk": {
|
||||
"name": "plugin_lifecycle_resource_cursors_pk",
|
||||
"columns": [
|
||||
"plugin_id",
|
||||
"company_id",
|
||||
"resource_type",
|
||||
"resource_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.plugin_logs": {
|
||||
"name": "plugin_logs",
|
||||
"schema": "",
|
||||
@@ -59516,6 +59603,13 @@
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"pending_reveal": {
|
||||
"name": "pending_reveal",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"allowed_actions": {
|
||||
"name": "allowed_actions",
|
||||
"type": "jsonb",
|
||||
+7
@@ -3249,6 +3249,13 @@
|
||||
"when": 1794400732201,
|
||||
"tag": "0465_better_auth_account_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 466,
|
||||
"version": "7",
|
||||
"when": 1794408985337,
|
||||
"tag": "0466_drop_project_environment",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { sql } from "drizzle-orm";
|
||||
import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import type { AgentEnvConfig } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { environments } from "./environments.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { connectionGrants } from "./tool_access.js";
|
||||
@@ -24,7 +23,6 @@ export const projects = pgTable(
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||
environmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }),
|
||||
gitAuthorizationGrantId: uuid("git_authorization_grant_id").references(() => connectionGrants.id, { onDelete: "set null" }),
|
||||
gitAuthorizedByUserId: text("git_authorized_by_user_id"),
|
||||
pauseReason: text("pause_reason"),
|
||||
|
||||
@@ -147,8 +147,6 @@ export interface Project {
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
/** Project-owned execution target; null inherits the agent/instance default. */
|
||||
environmentId?: string | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
|
||||
@@ -119,7 +119,6 @@ const projectFields = {
|
||||
color: z.string().optional().nullable(),
|
||||
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
|
||||
env: envConfigSchema.optional().nullable(),
|
||||
environmentId: z.string().guid().optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
};
|
||||
@@ -137,7 +136,7 @@ export const createProjectSchema = z.object({
|
||||
export type CreateProject = z.infer<typeof createProjectSchema>;
|
||||
|
||||
export const updateProjectSchema = objectWithoutDefaults(
|
||||
z.object({ ...projectFields, expectedStorageProviderPluginId: z.string().guid().nullable().optional() }),
|
||||
z.object(projectFields),
|
||||
).partial();
|
||||
|
||||
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
@@ -6,8 +6,6 @@ import { errorHandler } from "../middleware/index.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const projectPermissions = vi.hoisted(() => ({ canWrite: true }));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
@@ -65,7 +63,7 @@ vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(async (input: { action: string }) => ({
|
||||
allowed: ["project:read", "issue:read"].includes(input.action) || (projectPermissions.canWrite && ["tasks:assign", "project:write_workspace"].includes(input.action)),
|
||||
allowed: ["project:read", "issue:read"].includes(input.action),
|
||||
action: input.action,
|
||||
reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant",
|
||||
explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.",
|
||||
@@ -185,7 +183,6 @@ describe("execution environment route guards", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
projectPermissions.canWrite = true;
|
||||
mockProjectService.create.mockReset();
|
||||
mockProjectService.getById.mockReset();
|
||||
mockProjectService.update.mockReset();
|
||||
@@ -214,17 +211,12 @@ describe("execution environment route guards", () => {
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("accepts sandbox environments on project create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sandboxEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "fake-plugin" },
|
||||
});
|
||||
it("ignores a stale legacy policy environment on project create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
mockProjectService.create.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Sandboxed Project",
|
||||
name: "Legacy Project",
|
||||
status: "backlog",
|
||||
});
|
||||
const app = createProjectApp();
|
||||
@@ -232,35 +224,31 @@ describe("execution environment route guards", () => {
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/projects")
|
||||
.send({
|
||||
name: "Sandboxed Project",
|
||||
name: "Legacy Project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
environmentId: sandboxEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
||||
expect(mockProjectService.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts sandbox environments on project update", async () => {
|
||||
it("ignores a stale legacy policy environment on project update", async () => {
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Sandboxed Project",
|
||||
name: "Legacy Project",
|
||||
status: "backlog",
|
||||
archivedAt: null,
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sandboxEnvironmentId,
|
||||
companyId: "company-1",
|
||||
driver: "sandbox",
|
||||
config: { provider: "fake-plugin" },
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||
mockProjectService.update.mockResolvedValue({
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
name: "Sandboxed Project",
|
||||
name: "Legacy Project",
|
||||
status: "backlog",
|
||||
});
|
||||
const app = createProjectApp();
|
||||
@@ -274,18 +262,11 @@ describe("execution environment route guards", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(422);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnvironmentService.getById).not.toHaveBeenCalled();
|
||||
expect(mockProjectService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([sandboxEnvironmentId, null])("rejects a project environment change or clear without workspace-write permission (%s)", async environmentId => {
|
||||
projectPermissions.canWrite = false;
|
||||
mockProjectService.getById.mockResolvedValue({ id: "project-1", companyId: "company-1", environmentId: "other-environment" });
|
||||
const res = await request(createProjectApp()).patch("/api/projects/project-1").send({ environmentId });
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockProjectService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts sandbox environments on issue create", async () => {
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: sandboxEnvironmentId,
|
||||
|
||||
@@ -780,15 +780,6 @@ describe("operator default isolated execution workspaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("project-owned environment", () => {
|
||||
it("gives the project priority while keeping task trust and managed-only restrictions", () => {
|
||||
const defaults = { agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env" };
|
||||
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env" })).toEqual({ environmentId: "project-env", source: "project" });
|
||||
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "project-env", lowTrustIssueEnvironmentId: "task-sandbox" })).toEqual({ environmentId: "task-sandbox", source: "issue" });
|
||||
expect(resolveExecutionWorkspaceEnvironmentId({ ...defaults, projectEnvironmentId: "local-env", managedSandboxOnly: true, managedSandboxEnvironmentId: "managed-env" })).toEqual({ environmentId: "managed-env", source: "managed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("task-scoped low-trust environment", () => {
|
||||
it("uses the explicit phone-task sandbox while preserving ordinary agent defaults", () => {
|
||||
const defaults = {agentDefaultEnvironmentId: "agent-env", instanceDefaultEnvironmentId: "instance-env", localDefaultEnvironmentId: "local-env"};
|
||||
|
||||
@@ -590,7 +590,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
svc.update(f.input.projectId, {
|
||||
executionWorkspacePolicy: { storageProviderPluginId: null },
|
||||
}),
|
||||
).rejects.toThrow("stays attached");
|
||||
).rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
||||
await expect(
|
||||
svc.updateWorkspace(f.input.projectId, f.input.projectWorkspaceId, {
|
||||
repoUrl: "https://github.com/example/other.git",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
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, projects, userCompanyPreferences } from "@paperclipai/db";
|
||||
import { projectEnvironmentOptions, resolveProjectEnvironment } from "../services/project-environments.js";
|
||||
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 { environmentRunOrchestrator } from "../services/environment-run-orchestrator.js";
|
||||
import { environmentService } from "../services/environments.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";
|
||||
@@ -83,8 +80,8 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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 resolveProjectEnvironment(db, f.companyId, null)).toEqual({ environmentId: f.sandbox.id, executionWorkspacePolicy: null });
|
||||
expect(await resolveProjectEnvironment(db, f.companyId, null, { userId: "person" })).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: f.pluginId } });
|
||||
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 });
|
||||
@@ -101,69 +98,36 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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 resolveProjectEnvironment(db, f.companyId, explicit)).toEqual({ environmentId: null, executionWorkspacePolicy: explicit });
|
||||
await expect(resolveProjectEnvironment(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
|
||||
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("keeps environment changes on one project and snapshots creation defaults", async () => {
|
||||
it("saves storage, not a run environment, and keeps that storage after creation", async () => {
|
||||
const f = await fixture();
|
||||
const svc = projectService(db);
|
||||
const first = await svc.create(f.companyId, { name: "First project" });
|
||||
const second = await svc.create(f.companyId, { name: "Second project" });
|
||||
expect(first.environmentId).toBe(f.sandbox.id);
|
||||
expect(second.environmentId).toBe(f.sandbox.id);
|
||||
await svc.update(first.id, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
|
||||
await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id });
|
||||
expect((await svc.getById(first.id))?.environmentId).toBe(f.ssh.id);
|
||||
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
|
||||
expect((await svc.getById(first.id))?.executionWorkspacePolicy).toBeNull();
|
||||
expect((await db.select().from(agents).where(eq(agents.id, f.input.agentId)))[0].defaultEnvironmentId).toBeNull();
|
||||
expect((await environmentService(db).getDeleteBlastRadius(f.ssh.id))?.staticReferences.projectSelectionCount).toBe(1);
|
||||
expect(await svc.clearExecutionWorkspaceEnvironmentSelection(f.companyId, f.ssh.id)).toBe(1);
|
||||
expect((await svc.getById(first.id))?.environmentId).toBeNull();
|
||||
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
|
||||
const core = await svc.create(f.companyId, { name: "Core project" }, { environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
|
||||
const managed = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id });
|
||||
expect(core).not.toHaveProperty("environmentId");
|
||||
expect(core.workspaceProvider?.pluginId).toBeNull();
|
||||
expect(managed.workspaceProvider?.pluginId).toBe(f.pluginId);
|
||||
await expect(svc.update(core.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
||||
await expect(svc.update(managed.id, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
||||
expect((await svc.getById(core.id))?.workspaceProvider?.pluginId).toBeNull();
|
||||
expect((await svc.getById(managed.id))?.workspaceProvider?.pluginId).toBe(f.pluginId);
|
||||
});
|
||||
|
||||
it.each([{ enabled: true }, null])("preserves the storage owner before validating a combined environment/policy edit (%j)", async policy => {
|
||||
it("drops a legacy policy environment instead of saving it", async () => {
|
||||
const f = await fixture();
|
||||
const svc = projectService(db);
|
||||
const project = await svc.create(f.companyId, { name: "Managed project" }, { environmentId: f.computer.id });
|
||||
await expect(svc.update(project.id, { environmentId: f.local.id, executionWorkspacePolicy: policy }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
const saved = await svc.getById(project.id);
|
||||
expect(saved?.environmentId).toBe(f.computer.id);
|
||||
expect(saved?.executionWorkspacePolicy?.storageProviderPluginId).toBe(f.pluginId);
|
||||
});
|
||||
|
||||
it.each([false, true])("migrates a saved legacy project selection without changing its checkout policy (uppercase=%s)", async uppercase => {
|
||||
const f = await fixture();
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0464_project_environment.sql", import.meta.url), "utf8");
|
||||
const project = await projectService(db).create(f.companyId, { name: "Existing project" });
|
||||
const [target] = await db.insert(environments).values({ id: "abcdefab-cdef-4abc-8def-abcdefabcdef", name: "Legacy target", driver: "ssh", config: { host: "legacy.example.test" } }).returning();
|
||||
await expect(db.transaction(async tx => {
|
||||
await tx.update(projects).set({ executionWorkspacePolicy: { enabled: false, environmentId: uppercase ? target.id.toUpperCase() : target.id } }).where(eq(projects.id, project.id));
|
||||
await tx.execute(sql`alter table projects drop column environment_id`);
|
||||
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) {
|
||||
await tx.execute(sql.raw(statement));
|
||||
}
|
||||
const [saved] = await tx.select().from(projects).where(eq(projects.id, project.id));
|
||||
expect(saved.environmentId).toBe(target.id);
|
||||
expect(saved.executionWorkspacePolicy).toEqual({ enabled: false });
|
||||
throw new Error("rollback migration fixture");
|
||||
})).rejects.toThrow("rollback migration fixture");
|
||||
});
|
||||
|
||||
it("accepts legacy project policy environment edits without changing other projects", async () => {
|
||||
const f = await fixture();
|
||||
const svc = projectService(db);
|
||||
const first = await svc.create(f.companyId, { name: "Legacy editor" });
|
||||
const second = await svc.create(f.companyId, { name: "Other project" });
|
||||
const saved = await svc.update(first.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id.toUpperCase() } });
|
||||
expect(saved?.environmentId).toBe(f.ssh.id);
|
||||
expect(saved?.executionWorkspacePolicy).toEqual({ enabled: false });
|
||||
expect((await svc.getById(second.id))?.environmentId).toBe(f.sandbox.id);
|
||||
const created = await svc.create(f.companyId, { name: "Legacy create", executionWorkspacePolicy: { enabled: false, environmentId: f.ssh.id } });
|
||||
expect(created.executionWorkspacePolicy).toEqual({ enabled: false });
|
||||
const updated = await svc.update(created.id, { executionWorkspacePolicy: { enabled: false, environmentId: f.local.id } });
|
||||
expect(updated?.executionWorkspacePolicy).toEqual({ enabled: false });
|
||||
expect(updated).not.toHaveProperty("environmentId");
|
||||
});
|
||||
|
||||
it("preserves inherited task isolation when checking the observed core storage owner", async () => {
|
||||
@@ -198,15 +162,12 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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 expect(environmentRunOrchestrator(db).resolveEnvironment({ companyId: f.companyId, selectedEnvironmentId: f.ssh.id,
|
||||
localEnvironmentId: f.local.id })).rejects.toMatchObject({ status: 403, details: { code: "environment_company_mismatch" } });
|
||||
|
||||
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(resolveProjectEnvironment(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
|
||||
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,10 +175,10 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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 resolveProjectEnvironment(db, f.companyId, null)).toMatchObject({ executionWorkspacePolicy: { storageProviderPluginId: 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(resolveProjectEnvironment(db, f.companyId, null)).rejects.toMatchObject({ status: 409 });
|
||||
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 => {
|
||||
@@ -251,7 +212,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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(resolveProjectEnvironment(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
|
||||
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 () => {
|
||||
@@ -261,8 +222,6 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
result.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
result.use("/api", projectRoutes(db)); result.use(errorHandler); return result;
|
||||
}
|
||||
await db.insert(authUsers).values({ id: "person", name: "Person", email: "person@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "person", status: "active", membershipRole: "member" });
|
||||
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`);
|
||||
@@ -271,20 +230,7 @@ describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
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 });
|
||||
expect(created.body.environmentId).toBe(f.computer.id);
|
||||
const first = await request(board).post(`${url}/projects`).send({ name: "First core project" });
|
||||
const second = await request(board).post(`${url}/projects`).send({ name: "Second core project" });
|
||||
const updateUrl = `/api/projects/${first.body.id}`;
|
||||
const saved = await request(board).patch(updateUrl).send({ environmentId: f.ssh.id, expectedStorageProviderPluginId: null });
|
||||
expect(saved.status).toBe(200);
|
||||
expect(saved.body.environmentId).toBe(f.ssh.id);
|
||||
expect((await request(board).get(`/api/projects/${second.body.id}`)).body.environmentId).toBe(f.sandbox.id);
|
||||
expect((await db.select().from(instanceSettings))[0].defaultEnvironmentId).toBe(f.sandbox.id);
|
||||
expect((await request(app({ type: "none" })).patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404);
|
||||
expect((await request(app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() }))
|
||||
.patch(updateUrl).send({ environmentId: f.local.id })).status).toBe(404);
|
||||
expect((await request(board).patch(updateUrl).send({ environmentId: f.computer.id, expectedStorageProviderPluginId: null })).status).toBe(409);
|
||||
expect((await projectService(db).getById(first.body.id))?.environmentId).toBe(f.ssh.id);
|
||||
expect(created.body).not.toHaveProperty("environmentId");
|
||||
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);
|
||||
|
||||
@@ -114,18 +114,24 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: null }))!.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
|
||||
for (const storageProviderPluginId of [null, other.pluginId]) {
|
||||
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
|
||||
.rejects.toThrow("stays attached");
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
||||
}
|
||||
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspace!.id)))[0]).toEqual(workspace);
|
||||
});
|
||||
|
||||
it("lets a Core project move to a storage plugin, and then keeps it there", async () => {
|
||||
it("keeps a Core project on Core storage", async () => {
|
||||
const f = await fixture();
|
||||
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
|
||||
const svc = projectService(db);
|
||||
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } }))!.workspaceProvider)
|
||||
.toMatchObject({ source: "project", pluginId: f.pluginId });
|
||||
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } })).rejects.toThrow("stays attached");
|
||||
for (const storageProviderPluginId of [f.pluginId, other.pluginId]) {
|
||||
await expect(svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId } }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "project_storage_locked" } });
|
||||
}
|
||||
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, storageProviderPluginId: null } }))!.workspaceProvider)
|
||||
.toMatchObject({ pluginId: null });
|
||||
expect((await svc.update(f.input.projectId, { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" } }))!.workspaceProvider)
|
||||
.toMatchObject({ pluginId: null });
|
||||
});
|
||||
|
||||
it("preserves explicit alternatives and the explicit Core/local choice", async () => {
|
||||
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { appendWithCap } from "../adapters/utils.js";
|
||||
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||
@@ -87,7 +85,6 @@ export function projectRoutes(db: Db) {
|
||||
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
|
||||
});
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const environmentsSvc = environmentService(db);
|
||||
|
||||
/**
|
||||
* Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project
|
||||
@@ -114,21 +111,6 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) {
|
||||
if (environmentId === undefined || environmentId === null) return;
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ["local", "ssh", "sandbox"],
|
||||
});
|
||||
}
|
||||
|
||||
function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined {
|
||||
if (!policy || typeof policy !== "object" || !("environmentId" in policy)) {
|
||||
return undefined;
|
||||
}
|
||||
const environmentId = (policy as { environmentId?: unknown }).environmentId;
|
||||
return typeof environmentId === "string" || environmentId === null ? environmentId : undefined;
|
||||
}
|
||||
|
||||
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||
const companyIdQuery = req.query.companyId;
|
||||
const requestedCompanyId =
|
||||
@@ -173,9 +155,9 @@ export function projectRoutes(db: Db) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id?: string; companyId: string }) {
|
||||
async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
||||
const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null;
|
||||
const decision = await access.decide({ actor: req.actor, action: project.id ? "project:write_workspace" : "tasks:assign",
|
||||
const decision = await access.decide({ actor: req.actor, action: "project:write_workspace",
|
||||
resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId },
|
||||
scope: { projectId: project.id, assigneeAgentId } });
|
||||
if (decision.allowed) return true;
|
||||
@@ -401,12 +383,6 @@ export function projectRoutes(db: Db) {
|
||||
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
|
||||
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
||||
? await projectToolContext(db, req.actor, true) : null;
|
||||
if ((environmentId || readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy))
|
||||
&& !(await assertProjectWorkspaceWriteAllowed(req, res, { companyId }))) return;
|
||||
await assertProjectEnvironmentSelection(
|
||||
companyId,
|
||||
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
[
|
||||
@@ -524,9 +500,6 @@ export function projectRoutes(db: Db) {
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const body = { ...req.body };
|
||||
const nextEnvironmentId = body.environmentId !== undefined ? body.environmentId : readProjectPolicyEnvironmentId(body.executionWorkspacePolicy);
|
||||
const environmentChanged = nextEnvironmentId !== undefined && nextEnvironmentId !== (existing.environmentId ?? null);
|
||||
if (environmentChanged && !(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return;
|
||||
if (body.visibility !== undefined) {
|
||||
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
|
||||
}
|
||||
@@ -535,9 +508,6 @@ export function projectRoutes(db: Db) {
|
||||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
);
|
||||
if (environmentChanged && body.environmentId === undefined) {
|
||||
await assertProjectEnvironmentSelection(existing.companyId, nextEnvironmentId);
|
||||
}
|
||||
if (typeof body.archivedAt === "string") {
|
||||
body.archivedAt = new Date(body.archivedAt);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withEnvironmentLeaseController } from "./environment-lease-controller.j
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { SANDBOX_PLATFORM_PROBE_METADATA_KEY } from "./environment-custom-image-runtime.js";
|
||||
import { and, asc, desc, eq, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, ne, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
@@ -1390,7 +1390,7 @@ export function environmentService(db: Db) {
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(or(eq(projects.environmentId, id), sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`)),
|
||||
.where(sql`${projects.executionWorkspacePolicy} ->> 'environmentId' = ${id}`),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(companySecretBindings)
|
||||
|
||||
@@ -274,7 +274,6 @@ export function selectEnvironmentExecutionWorkspaceSettings(
|
||||
|
||||
export type ExecutionWorkspaceEnvironmentSource =
|
||||
| "issue"
|
||||
| "project"
|
||||
| "run"
|
||||
| "agent"
|
||||
| "instance"
|
||||
@@ -300,7 +299,6 @@ export class ManagedSandboxUnavailableError extends Error {
|
||||
export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
/** Explicit operator-selected sandbox for a task with a low-trust boundary. */
|
||||
lowTrustIssueEnvironmentId?: string | null;
|
||||
projectEnvironmentId?: string | null;
|
||||
agentDefaultEnvironmentId: string | null;
|
||||
instanceDefaultEnvironmentId: string | null;
|
||||
localDefaultEnvironmentId: string;
|
||||
@@ -319,9 +317,6 @@ export function resolveExecutionWorkspaceEnvironmentId(input: {
|
||||
if (input.lowTrustIssueEnvironmentId) {
|
||||
return {environmentId: input.lowTrustIssueEnvironmentId, source: "issue"};
|
||||
}
|
||||
if (input.projectEnvironmentId) {
|
||||
return { environmentId: input.projectEnvironmentId, source: "project" };
|
||||
}
|
||||
if (input.agentDefaultEnvironmentId) {
|
||||
return {
|
||||
environmentId: input.agentDefaultEnvironmentId,
|
||||
|
||||
@@ -82,40 +82,24 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary",
|
||||
return { input, issue, project, workspace, recover };
|
||||
}
|
||||
|
||||
it("uses each project's saved environment before the shared agent and instance defaults", async () => {
|
||||
it("places a new run by the agent default, not by its project", async () => {
|
||||
const f = await fixture();
|
||||
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
|
||||
const [target] = await db.insert(environments).values({ name: `Project host ${randomUUID()}`, driver: "ssh", config: { host: "project.example.test" } }).returning();
|
||||
const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project", environmentId: local.id }).returning();
|
||||
await db.update(projects).set({ environmentId: target.id }).where(eq(projects.id, f.project.id));
|
||||
const [other] = await db.insert(projects).values({ companyId: f.input.agent.companyId, name: "Other project" }).returning();
|
||||
f.input.agent.defaultEnvironmentId = local.id;
|
||||
f.input.policy.isolatedWorkspacesEnabled = false;
|
||||
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: target.id, source: "project" });
|
||||
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" });
|
||||
f.input.task.executionProjectId = other.id;
|
||||
f.input.task.issueRef!.projectId = other.id;
|
||||
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" });
|
||||
f.input.task.executionProjectId = f.project.id;
|
||||
f.input.task.issueRef!.projectId = f.project.id;
|
||||
await db.update(projects).set({ environmentId: null }).where(eq(projects.id, f.project.id));
|
||||
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "agent" });
|
||||
});
|
||||
|
||||
it("keeps task project placement when files come from another project", async () => {
|
||||
const f = await fixture();
|
||||
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
|
||||
const [source] = await db.insert(projects).values({ companyId: f.project.companyId, name: "File source" }).returning();
|
||||
await db.update(projects).set({ environmentId: local.id }).where(eq(projects.id, f.project.id));
|
||||
f.input.task.executionProjectId = source.id;
|
||||
expect((await selectHeartbeatEnvironment(db, f.input)).environmentResolution).toEqual({ environmentId: local.id, source: "project" });
|
||||
});
|
||||
|
||||
it("restores the admitted environment after a project edit and rejects a foreign lease", async () => {
|
||||
it("restores the admitted environment after a default change and rejects a foreign lease", async () => {
|
||||
const f = await fixture();
|
||||
const local = await environmentService(db).ensureLocalEnvironment(f.input.agent.companyId);
|
||||
const [next] = await db.insert(environments).values({ name: `Next target ${randomUUID()}`, driver: "ssh", config: { host: "next.example.test" } }).returning();
|
||||
const saved = await f.workspace();
|
||||
f.recover(saved.id);
|
||||
await db.update(projects).set({ environmentId: next.id }).where(eq(projects.id, f.project.id));
|
||||
f.input.agent.defaultEnvironmentId = next.id;
|
||||
const [lease] = await db.insert(environmentLeases).values({ companyId: f.project.companyId, environmentId: local.id,
|
||||
heartbeatRunId: f.input.run.id, issueId: f.issue.id, executionWorkspaceId: saved.id, metadata: { agentId: f.input.agent.id } }).returning();
|
||||
f.input.run.contextSnapshot = { paperclipEnvironment: { id: local.id, leaseId: lease.id } };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { assertProviderApiPlacementPolicy, resolveProviderApiPlacement } from "../native-runtime/provider-api-placement.js";
|
||||
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
import { heartbeatRuns, issues, projects, type Db, type agents } from "@paperclipai/db";
|
||||
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { readGitCacheExpiration } from "../git-workspace-restoration.js";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared";
|
||||
@@ -47,7 +47,7 @@ export interface HeartbeatEnvironmentSelectionInput {
|
||||
"id" | "companyId" | "adapterType" | "adapterConfig" | "defaultEnvironmentId">;
|
||||
task: {
|
||||
freshWorkspace?: import("../fresh-workspace-continuation.js").ContinuationReadiness | null;
|
||||
issueRef: Pick<Issue, "id" | "projectWorkspaceId" | "executionWorkspaceId" | "executionWorkspacePreference"> & { projectId?: string | null } | null;
|
||||
issueRef: Pick<Issue, "id" | "projectWorkspaceId" | "executionWorkspaceId" | "executionWorkspacePreference"> | null;
|
||||
issueContext: Pick<Issue, "assigneeAgentId" | "executionWorkspaceSettings"> | null;
|
||||
issueId: string | null;
|
||||
context: Record<string, unknown>;
|
||||
@@ -203,12 +203,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
const managedSandboxEnvironment = managedSandboxOnly && !providerApiPlacement
|
||||
? await environmentsSvc.findManagedSandboxEnvironment(agent.companyId)
|
||||
: null;
|
||||
const placementProjectId = issueRef ? issueRef.projectId ?? null : executionProjectId;
|
||||
const [projectEnvironment] = placementProjectId && !controllerOnly && !persistedNativeExecutionInput
|
||||
? await db.select({ environmentId: projects.environmentId }).from(projects)
|
||||
.where(and(eq(projects.id, placementProjectId), eq(projects.companyId, agent.companyId)))
|
||||
: [];
|
||||
// A project edit affects new admissions, never a surviving provider process.
|
||||
const savedEnvironment = parseObject(parseObject(run.contextSnapshot).paperclipEnvironment);
|
||||
const admittedLeaseId = persistedNativeExecutionInput && !controllerOnly
|
||||
? readNonEmptyString(savedEnvironment.leaseId) ?? readNativeWorkspaceSyncReference(persistedRunnerProfile.nativeWorkspaceSync)?.leaseId
|
||||
@@ -227,7 +221,6 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: resolveExecutionWorkspaceEnvironmentId({
|
||||
lowTrustIssueEnvironmentId: trustPreset.kind === "low_trust_review"
|
||||
? parseIssueExecutionWorkspaceSettings(issueContext?.executionWorkspaceSettings, {includeEnvironmentId: true})?.environmentId : null,
|
||||
projectEnvironmentId: projectEnvironment?.environmentId,
|
||||
agentDefaultEnvironmentId: agent.defaultEnvironmentId,
|
||||
instanceDefaultEnvironmentId:
|
||||
resolvedInstanceSettings.defaultEnvironmentId ?? null,
|
||||
|
||||
@@ -70,9 +70,9 @@ export async function projectEnvironmentOptions(db: Db, companyId: string, actor
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveProjectEnvironment(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) {
|
||||
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 { environmentId: null, executionWorkspacePolicy: rawPolicy };
|
||||
if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy;
|
||||
const options = await projectEnvironmentOptions(db, companyId, actor);
|
||||
const environmentId = actor.environmentId?.toLowerCase() ?? options.defaultEnvironmentId;
|
||||
const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId));
|
||||
@@ -85,7 +85,7 @@ export async function resolveProjectEnvironment(db: Db, companyId: string, rawPo
|
||||
if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) {
|
||||
throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" });
|
||||
}
|
||||
return { environmentId: environmentId ?? null, executionWorkspacePolicy: pluginId || policy?.storageProviderPluginId !== undefined
|
||||
return pluginId || policy?.storageProviderPluginId !== undefined
|
||||
? { enabled: true, ...policy, storageProviderPluginId: pluginId }
|
||||
: rawPolicy };
|
||||
: rawPolicy;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { resolveProjectEnvironment, type ProjectCreationEnvironment } from "./project-environments.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";
|
||||
@@ -59,6 +59,12 @@ function legacyProjectEnvironmentId(policy: Record<string, unknown> | null | und
|
||||
return typeof id === "string" || id === null ? id : undefined;
|
||||
}
|
||||
|
||||
function withoutLegacyProjectEnvironment<T extends Record<string, unknown> | null | undefined>(policy: T): T {
|
||||
if (legacyProjectEnvironmentId(policy) === undefined) return policy;
|
||||
const normalized = parseProjectExecutionWorkspacePolicy(policy);
|
||||
return (normalized ? { ...normalized } : null) as T;
|
||||
}
|
||||
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
@@ -681,14 +687,8 @@ 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 legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
|
||||
const workspacePolicy = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
|
||||
const selected = await resolveProjectEnvironment(db, companyId,
|
||||
legacyEnvironmentId !== undefined ? workspacePolicy : projectData.executionWorkspacePolicy ?? null, {
|
||||
...environment, environmentId: environment.environmentId ?? projectData.environmentId ?? legacyEnvironmentId ?? undefined,
|
||||
});
|
||||
const executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
|
||||
projectData.environmentId = selected.environmentId;
|
||||
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId,
|
||||
withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy) ?? null, environment) as typeof projectData.executionWorkspacePolicy;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
@@ -984,48 +984,28 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[]; expectedStorageProviderPluginId?: string | null },
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals | null> => db.transaction(async tx => {
|
||||
const db = tx as unknown as Db;
|
||||
const { goalIds: inputGoalIds, expectedStorageProviderPluginId, ...projectData } = data;
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, environmentId: projects.environmentId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name, archivedAt: projects.archivedAt, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id)).for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
const legacyEnvironmentId = legacyProjectEnvironmentId(projectData.executionWorkspacePolicy);
|
||||
if (projectData.environmentId === undefined && legacyEnvironmentId !== undefined) {
|
||||
projectData.environmentId = legacyEnvironmentId;
|
||||
}
|
||||
if (legacyEnvironmentId !== undefined) {
|
||||
const normalized = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy);
|
||||
projectData.executionWorkspacePolicy = normalized ? { ...normalized } : null;
|
||||
}
|
||||
projectData.executionWorkspacePolicy = withoutLegacyProjectEnvironment(projectData.executionWorkspacePolicy);
|
||||
const currentStorage = parseProjectExecutionWorkspacePolicy(existingProject.executionWorkspacePolicy)?.storageProviderPluginId;
|
||||
if (currentStorage && projectData.executionWorkspacePolicy !== undefined) {
|
||||
if (projectData.executionWorkspacePolicy !== undefined) {
|
||||
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
|
||||
if (requestedStorage !== undefined && requestedStorage !== currentStorage) {
|
||||
throw conflict("A plugin-backed project stays attached to its storage plugin");
|
||||
if (requestedStorage !== undefined && requestedStorage !== (currentStorage ?? null)) {
|
||||
throw conflict("A project stays on the workspace storage chosen when it was created", { code: "project_storage_locked" });
|
||||
}
|
||||
if (requestedStorage === undefined) {
|
||||
if (requestedStorage === undefined && currentStorage) {
|
||||
projectData.executionWorkspacePolicy = { enabled: true, ...projectData.executionWorkspacePolicy, storageProviderPluginId: currentStorage };
|
||||
}
|
||||
}
|
||||
const requestedStorage = parseProjectExecutionWorkspacePolicy(projectData.executionWorkspacePolicy)?.storageProviderPluginId;
|
||||
const storageChanged = requestedStorage !== undefined && requestedStorage !== currentStorage;
|
||||
const selectedEnvironmentId = projectData.environmentId === undefined && storageChanged
|
||||
? existingProject.environmentId : projectData.environmentId;
|
||||
if (selectedEnvironmentId && (selectedEnvironmentId !== existingProject.environmentId || storageChanged || expectedStorageProviderPluginId !== undefined)) {
|
||||
const selected = await resolveProjectEnvironment(db, existingProject.companyId,
|
||||
projectData.executionWorkspacePolicy ?? existingProject.executionWorkspacePolicy,
|
||||
{ environmentId: selectedEnvironmentId, expectedStorageProviderPluginId });
|
||||
projectData.executionWorkspacePolicy = selected.executionWorkspacePolicy as typeof projectData.executionWorkspacePolicy;
|
||||
}
|
||||
if (!currentStorage && requestedStorage) {
|
||||
await retireRemovedPluginWorkspaceDependents(db, id);
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
|
||||
}
|
||||
@@ -1101,7 +1081,6 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
environmentId: projects.environmentId,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
@@ -1109,12 +1088,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
|
||||
let cleared = 0;
|
||||
for (const row of rows) {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy);
|
||||
if (row.environmentId !== environmentId && legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue;
|
||||
if (legacyProjectEnvironmentId(row.executionWorkspacePolicy) !== environmentId) continue;
|
||||
|
||||
await projectService(db).update(row.id, {
|
||||
environmentId: null,
|
||||
...(legacyProjectEnvironmentId(row.executionWorkspacePolicy) === environmentId ? { executionWorkspacePolicy: policy ? { ...policy } : null } : {}),
|
||||
executionWorkspacePolicy: withoutLegacyProjectEnvironment(row.executionWorkspacePolicy),
|
||||
});
|
||||
cleared += 1;
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
|
||||
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
|
||||
"packages/shared/src/types/plugin.ts": "36e0f50d97f96532cb1fbdf3223368d92a7e9b1e",
|
||||
"packages/shared/src/types/project.ts": "2a20b4d8772d5f0fa5015828818b025abc7cedcf",
|
||||
"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",
|
||||
@@ -487,7 +487,7 @@
|
||||
"packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53",
|
||||
"packages/shared/src/validators/plugin.test.ts": "23bc443850d46d815877a71a44625fd42aebb00a",
|
||||
"packages/shared/src/validators/plugin.ts": "054de474635e080073e85413211cca4b3eb8681b",
|
||||
"packages/shared/src/validators/project.ts": "dc051327b2eaf4a28288a84e21c1f3baab906bc9",
|
||||
"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",
|
||||
|
||||
@@ -201,7 +201,6 @@ describe("IssueWorkspaceCard", () => {
|
||||
issue={createIssue()}
|
||||
project={{
|
||||
id: "project-1",
|
||||
environmentId: "env-project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
@@ -263,7 +262,6 @@ describe("IssueWorkspaceCard", () => {
|
||||
issue={createIssue()}
|
||||
project={{
|
||||
id: "project-1",
|
||||
environmentId: "env-project",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
|
||||
@@ -165,7 +165,6 @@ interface IssueWorkspaceCardProps {
|
||||
currentExecutionWorkspace?: ExecutionWorkspace | null;
|
||||
};
|
||||
project: {
|
||||
environmentId?: string | null;
|
||||
id: string;
|
||||
executionWorkspacePolicy?: {
|
||||
enabled?: boolean;
|
||||
@@ -254,15 +253,11 @@ export function IssueWorkspaceCard({
|
||||
|
||||
const [draftSelection, setDraftSelection] = useState(currentSelection);
|
||||
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
|
||||
const projectEnvironmentId = environmentsEnabled
|
||||
? project?.environmentId ?? null
|
||||
: null;
|
||||
const currentReusableEnvironmentId = selectedReusableExecutionWorkspace?.config?.environmentId ?? null;
|
||||
const currentEnvironmentId = environmentsEnabled
|
||||
? (
|
||||
(currentSelection === "reuse_existing" && currentReusableEnvironmentId)
|
||||
?? workspace?.config?.environmentId
|
||||
?? projectEnvironmentId
|
||||
)
|
||||
: null;
|
||||
const currentEnvironment =
|
||||
@@ -411,8 +406,6 @@ export function IssueWorkspaceCard({
|
||||
Environment: <span className="text-foreground">{currentEnvironment?.name ?? currentEnvironmentId}</span>
|
||||
{currentSelection === "reuse_existing" && currentReusableEnvironmentId === currentEnvironmentId
|
||||
? " · reused workspace"
|
||||
: !issue.executionWorkspaceSettings?.environmentId && projectEnvironmentId === currentEnvironmentId
|
||||
? " · project default"
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,12 @@ function act(callback: () => void) {
|
||||
|
||||
const noop = vi.hoisted(() => () => undefined);
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
|
||||
const environmentOptions = vi.hoisted(() => ({ groups: [
|
||||
{ storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "sandbox", name: "Paperclip Sandbox" }] },
|
||||
{ storageProviderPluginId: "provider-one", environments: [{ id: "computer", name: "Agent computers" }] },
|
||||
], defaultEnvironmentId: "local", error: null }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn(),
|
||||
environmentOptions: vi.fn(async () => environmentOptions) } }));
|
||||
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
||||
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
|
||||
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
|
||||
@@ -57,6 +62,7 @@ function primedClient(hiddenSettings: string[] = []) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(queryKeys.health, { hiddenSettings });
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
|
||||
client.setQueryData(queryKeys.projects.environments("company-1"), environmentOptions);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -140,31 +146,44 @@ it("hides project isolation controls without rewriting its policy", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("ProjectProperties — workspace storage selection", () => {
|
||||
describe("ProjectProperties — workspace storage availability", () => {
|
||||
const inherited = {
|
||||
source: "core" as const, pluginId: null, displayName: "Managed storage", error: null,
|
||||
effectivePolicy: { enabled: true, defaultMode: "isolated_workspace" as const, storageProviderPluginId: "provider-one", workspaceStrategy: { type: "git_worktree" as const } },
|
||||
providers: [{ id: "provider-one", displayName: "Managed storage", isDefault: true }, { id: "provider-two", displayName: "Alternative", isDefault: false }],
|
||||
};
|
||||
function storageSelect() { return container.querySelector<HTMLSelectElement>('select[aria-label="Workspace storage provider"]')!; }
|
||||
it("shows an unattached project on Core storage and offers each plugin", () => {
|
||||
render(makeProject({ executionWorkspacePolicy: null, workspaceProvider: inherited }), vi.fn());
|
||||
expect(storageSelect().value).toBe("core");
|
||||
expect(storageSelect().disabled).toBe(false);
|
||||
expect(Array.from(storageSelect().options).map(option => option.value)).toEqual(["core", "provider-one", "provider-two"]);
|
||||
expect(container.textContent).toContain("A project moved to a storage plugin stays on that plugin.");
|
||||
const core = { source: "core" as const, pluginId: null, displayName: "Core / local", error: null, effectivePolicy: null, providers: inherited.providers };
|
||||
function note() { return container.querySelector('[data-testid="project-storage-availability"]'); }
|
||||
it("offers no storage or environment control", () => {
|
||||
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn());
|
||||
expect(container.querySelector('select[aria-label="Workspace storage provider"]')).toBeNull();
|
||||
expect(container.querySelector('select[id^="project-environment-"]')).toBeNull();
|
||||
});
|
||||
it("saves a move to a plugin while preserving explicit settings", () => {
|
||||
const onFieldUpdate = vi.fn();
|
||||
render(makeProject({ executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize" }, workspaceProvider: inherited }), onFieldUpdate);
|
||||
act(() => { storageSelect().value = "provider-two"; storageSelect().dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(onFieldUpdate).toHaveBeenCalledWith("workspace_storage_provider", { executionWorkspacePolicy: { enabled: true, sharedWorkspaceConcurrency: "serialize", storageProviderPluginId: "provider-two" } });
|
||||
it("hides the note when no storage plugin is installed", () => {
|
||||
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: { ...core, providers: [] } }), vi.fn());
|
||||
expect(note()).toBeNull();
|
||||
expect(container.textContent).not.toContain("Available in");
|
||||
});
|
||||
it("locks the control on a plugin-backed project", () => {
|
||||
render(makeProject({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" }, workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn());
|
||||
expect(storageSelect().value).toBe("provider-one");
|
||||
expect(storageSelect().disabled).toBe(true);
|
||||
expect(container.textContent).toContain("This project stays on Managed storage.");
|
||||
it("lists the environments that share a core project's storage when a plugin is installed", () => {
|
||||
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: null, workspaceProvider: core }), vi.fn());
|
||||
expect(note()?.textContent).toContain("Available in");
|
||||
expect(note()?.textContent).toContain("Local, Paperclip Sandbox");
|
||||
expect(note()?.textContent).not.toContain("Agent computers");
|
||||
expect(note()?.textContent).toContain("Core / local workspace storage was chosen when this project was created");
|
||||
});
|
||||
it("lists the plugin's environments for a plugin-backed project", () => {
|
||||
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" },
|
||||
workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one" } }), vi.fn());
|
||||
expect(note()?.textContent).toContain("Agent computers");
|
||||
expect(note()?.textContent).not.toContain("Paperclip Sandbox");
|
||||
expect(note()?.textContent).toContain("Managed storage workspace storage");
|
||||
});
|
||||
it("reports an uninstalled storage plugin instead of hiding the note", () => {
|
||||
render(makeProject({ companyId: "company-1", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "provider-one" },
|
||||
workspaceProvider: { ...inherited, source: "project" as const, pluginId: "provider-one", displayName: "Unavailable provider",
|
||||
providers: [], error: "The workspace storage plugin is unavailable" } }), vi.fn());
|
||||
expect(note()?.querySelector('[role="alert"]')?.textContent).toBe("Storage plugin unavailable");
|
||||
expect(note()?.querySelectorAll('[role="alert"]')).toHaveLength(1);
|
||||
});
|
||||
it("does not copy inherited mode, strategy, or provider when another setting changes", () => {
|
||||
const onFieldUpdate = vi.fn();
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { ProjectProperties } from "./ProjectProperties";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const options = vi.hoisted(() => ({ groups: [
|
||||
{ storageProviderPluginId: null, environments: [{ id: "local", name: "Local" }, { id: "ssh", name: "Build host" }] },
|
||||
{ storageProviderPluginId: "plugin", environments: [{ id: "computer", name: "Computers" }] },
|
||||
], defaultEnvironmentId: "local", error: null }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { environmentOptions: vi.fn(async () => options) } }));
|
||||
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn(async () => []), listUserSecretDefinitions: vi.fn(async () => []) } }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn(async () => ({ enableEnvironments: true, enableIsolatedWorkspaces: false })) } }));
|
||||
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company", companies: [] }) }));
|
||||
vi.mock("./ProjectRepositories", () => ({ ProjectRepositories: () => null }));
|
||||
vi.mock("./ProjectGitAuthorization", () => ({ ProjectGitAuthorization: () => null }));
|
||||
vi.mock("./ProjectAccessMembers", () => ({ ProjectAccessMembers: () => null }));
|
||||
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
|
||||
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
|
||||
|
||||
const update = vi.fn();
|
||||
let container: HTMLDivElement, root: Root;
|
||||
beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
|
||||
afterEach(() => { flushSync(() => root.unmount()); container.remove(); vi.clearAllMocks(); });
|
||||
|
||||
function makeProject(id: string): Project {
|
||||
return { id, companyId: "company", name: id, urlKey: id, status: "planned", goalIds: [], goals: [], env: null,
|
||||
environmentId: "local", executionWorkspacePolicy: null, workspaces: [], primaryWorkspace: null,
|
||||
workspaceProvider: { source: "core", pluginId: null, displayName: "Core / local", providers: [{ id: "plugin", displayName: "Managed storage" }], effectivePolicy: null, error: null },
|
||||
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
|
||||
} as unknown as Project;
|
||||
}
|
||||
|
||||
function renderProjects(seedOptions = true) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableEnvironments: true, enableIsolatedWorkspaces: false });
|
||||
if (seedOptions) client.setQueryData(queryKeys.projects.environments("company"), options);
|
||||
function Projects() {
|
||||
const [projects, setProjects] = useState([makeProject("first"), makeProject("second")]);
|
||||
return projects.map(project => <fieldset key={project.id} data-project={project.id}>
|
||||
<ProjectProperties project={project} onFieldUpdate={(field, data) => {
|
||||
update(project.id, field, data);
|
||||
setProjects(previous => previous.map(value => value.id === project.id ? { ...value, ...data } as Project : value));
|
||||
}} />
|
||||
</fieldset>);
|
||||
}
|
||||
flushSync(() => root.render(<QueryClientProvider client={client}><TooltipProvider><Projects /></TooltipProvider></QueryClientProvider>));
|
||||
return client;
|
||||
}
|
||||
|
||||
it("keeps environment selection on its project with isolation disabled and a shared query cache", () => {
|
||||
const client = renderProjects();
|
||||
const first = container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!;
|
||||
const second = container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!;
|
||||
expect(first.value).toBe("local");
|
||||
expect(second.value).toBe("local");
|
||||
expect([...first.options].map(option => option.value)).not.toContain("computer");
|
||||
flushSync(() => { first.value = "ssh"; first.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: "ssh", expectedStorageProviderPluginId: null });
|
||||
expect(first.value).toBe("ssh");
|
||||
expect(second.value).toBe("local");
|
||||
expect(client.getQueryData(queryKeys.projects.environments("company"))).toEqual(options);
|
||||
flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(update).toHaveBeenLastCalledWith("first", "execution_workspace_environment", { environmentId: null });
|
||||
expect(second.value).toBe("local");
|
||||
});
|
||||
|
||||
it("changes storage and the compatible environment together on only the selected project", () => {
|
||||
renderProjects();
|
||||
const firstStorage = container.querySelector<HTMLSelectElement>('[data-project="first"] select#workspace-storage-provider')!;
|
||||
flushSync(() => { firstStorage.value = "plugin"; firstStorage.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("first", "workspace_storage_provider", {
|
||||
executionWorkspacePolicy: { enabled: true, storageProviderPluginId: "plugin" },
|
||||
environmentId: "computer", expectedStorageProviderPluginId: "plugin",
|
||||
});
|
||||
expect(container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!.value).toBe("computer");
|
||||
expect(container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local");
|
||||
});
|
||||
|
||||
it("keeps the saved mapping and allows an explicit clear while the environment list loads", () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockReturnValueOnce(new Promise(() => {}));
|
||||
renderProjects(false);
|
||||
const first = container.querySelector<HTMLSelectElement>('[data-project="first"] select[id^="project-environment-"]')!;
|
||||
expect(first.value).toBe("local");
|
||||
expect(first.selectedOptions[0].textContent).toBe("Saved environment");
|
||||
expect(first.disabled).toBe(false);
|
||||
flushSync(() => { first.value = ""; first.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("first", "execution_workspace_environment", { environmentId: null });
|
||||
expect(container.querySelector<HTMLSelectElement>('[data-project="second"] select[id^="project-environment-"]')!.value).toBe("local");
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
import { NativeSelect } from "./ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProjectAccessMembers } from "./ProjectAccessMembers";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
@@ -48,11 +47,9 @@ export type ProjectConfigFieldKey =
|
||||
| "visibility"
|
||||
| "goals"
|
||||
| "env"
|
||||
| "workspace_storage_provider"
|
||||
| "execution_workspace_enabled"
|
||||
| "execution_workspace_default_mode"
|
||||
| "execution_workspace_shared_concurrency"
|
||||
| "execution_workspace_environment"
|
||||
| "execution_workspace_base_ref"
|
||||
| "execution_workspace_branch_template"
|
||||
| "execution_workspace_worktree_parent_dir"
|
||||
@@ -240,7 +237,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
@@ -263,10 +259,15 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
||||
},
|
||||
});
|
||||
const providerConfiguration = project.workspaceProvider;
|
||||
const projectStoragePluginId = project.executionWorkspacePolicy?.storageProviderPluginId ?? null;
|
||||
const showStorageAvailability = Boolean(projectStoragePluginId || providerConfiguration?.providers.length);
|
||||
const storagePluginUnavailable = Boolean(projectStoragePluginId
|
||||
&& !providerConfiguration?.providers.some(provider => provider.id === projectStoragePluginId));
|
||||
const projectEnvironments = useQuery({
|
||||
queryKey: queryKeys.projects.environments(project.companyId),
|
||||
queryFn: () => projectsApi.environmentOptions(project.companyId),
|
||||
enabled: Boolean(project.companyId),
|
||||
enabled: Boolean(project.companyId) && showStorageAvailability && !storagePluginUnavailable,
|
||||
});
|
||||
const projectEnvironmentView = useQueryView(projectEnvironments);
|
||||
|
||||
@@ -275,7 +276,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
|
||||
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
|
||||
const storedExecutionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
|
||||
const providerConfiguration = project.workspaceProvider;
|
||||
const executionWorkspacePolicy = providerConfiguration?.effectivePolicy ?? storedExecutionWorkspacePolicy;
|
||||
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
@@ -298,9 +298,9 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
// policy reads as "not managed", which would show the local folder the policy
|
||||
// exists to hide.
|
||||
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
|
||||
const projectEnvironmentChoices = projectEnvironmentView.data?.groups
|
||||
.filter(group => group.storageProviderPluginId === (storedExecutionWorkspacePolicy?.storageProviderPluginId ?? null))
|
||||
.flatMap(group => group.environments) ?? [];
|
||||
const storageEnvironmentNames = projectEnvironmentView.data?.groups
|
||||
.filter(group => group.storageProviderPluginId === projectStoragePluginId)
|
||||
.flatMap(group => group.environments.map(environment => environment.name)) ?? [];
|
||||
|
||||
const invalidateProject = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||
@@ -726,70 +726,24 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{environmentsEnabled && project.companyId ? (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<label htmlFor={`project-environment-${project.id}`} className="flex items-center gap-2 text-sm font-medium">
|
||||
Environment
|
||||
<SaveIndicator state={fieldState("execution_workspace_environment")} />
|
||||
</label>
|
||||
<NativeSelect id={`project-environment-${project.id}`} value={project.environmentId ?? ""}
|
||||
disabled={(!onUpdate && !onFieldUpdate) || fieldState("execution_workspace_environment") === "saving"}
|
||||
onChange={event => {
|
||||
const environmentId = event.target.value || null;
|
||||
const group = projectEnvironmentView.data?.groups.find(value => value.environments.some(environment => environment.id === environmentId));
|
||||
commitField("execution_workspace_environment", { environmentId,
|
||||
...(group ? { expectedStorageProviderPluginId: group.storageProviderPluginId } : {}) });
|
||||
}}>
|
||||
<option value="">Use agent default</option>
|
||||
{project.environmentId && !projectEnvironmentChoices.some(environment => environment.id === project.environmentId)
|
||||
? <option value={project.environmentId}>{projectEnvironmentView.data ? "Unavailable environment" : "Saved environment"}</option> : null}
|
||||
{projectEnvironmentChoices.map(environment => <option key={environment.id} value={environment.id}>{environment.name}</option>)}
|
||||
</NativeSelect>
|
||||
<p className="text-xs text-muted-foreground">Applies only to this project. Agent and company defaults stay unchanged.</p>
|
||||
{projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting"
|
||||
? <p role="status" className="text-xs text-muted-foreground">Loading environments…</p> : null}
|
||||
{projectEnvironmentView.kind === "error" ? <QueryErrorState error={projectEnvironmentView.error} size="inline"
|
||||
action="load project environments" onRetry={projectEnvironmentView.retry} retrying={projectEnvironmentView.isFetching} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{providerConfiguration ? (
|
||||
<div className="py-1.5 space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium" htmlFor="workspace-storage-provider">
|
||||
Workspace storage
|
||||
<SaveIndicator state={fieldState("workspace_storage_provider")} />
|
||||
</label>
|
||||
<select
|
||||
id="workspace-storage-provider"
|
||||
aria-label="Workspace storage provider"
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
disabled={Boolean(storedExecutionWorkspacePolicy?.storageProviderPluginId) || (!onUpdate && !onFieldUpdate) || Boolean(project.companyId && !projectEnvironmentView.data)}
|
||||
value={storedExecutionWorkspacePolicy?.storageProviderPluginId ?? "core"}
|
||||
onChange={(event) => {
|
||||
const policy = { enabled: true, ...storedExecutionWorkspacePolicy };
|
||||
policy.storageProviderPluginId = event.target.value === "core" ? null : event.target.value;
|
||||
const group = projectEnvironmentView.data?.groups.find(value => value.storageProviderPluginId === policy.storageProviderPluginId);
|
||||
const target = group?.environments.find(environment => environment.id === project.environmentId)
|
||||
?? group?.environments.find(environment => environment.id === projectEnvironmentView.data?.defaultEnvironmentId)
|
||||
?? group?.environments[0];
|
||||
commitField("workspace_storage_provider", { executionWorkspacePolicy: policy,
|
||||
...(target ? { environmentId: target.id, expectedStorageProviderPluginId: policy.storageProviderPluginId } : {}) });
|
||||
}}
|
||||
>
|
||||
<option value="core">Core / local</option>
|
||||
{providerConfiguration.providers.map(provider => (
|
||||
<option key={provider.id} value={provider.id} disabled={Boolean(project.companyId && !projectEnvironmentView.data?.groups.some(group => group.storageProviderPluginId === provider.id))}>{provider.displayName}</option>
|
||||
))}
|
||||
{storedExecutionWorkspacePolicy?.storageProviderPluginId && !providerConfiguration.providers.some(provider => provider.id === storedExecutionWorkspacePolicy.storageProviderPluginId) ? (
|
||||
<option value={storedExecutionWorkspacePolicy.storageProviderPluginId}>Unavailable provider</option>
|
||||
) : null}
|
||||
</select>
|
||||
{showStorageAvailability ? (
|
||||
<div className="py-1.5 space-y-1" data-testid="project-storage-availability">
|
||||
<div className="text-sm font-medium">Available in</div>
|
||||
{storagePluginUnavailable ? (
|
||||
<p role="alert" className="text-sm text-destructive">Storage plugin unavailable</p>
|
||||
) : projectEnvironmentView.kind === "loading" || projectEnvironmentView.kind === "reconnecting" ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">Loading environments…</p>
|
||||
) : projectEnvironmentView.kind === "error" ? (
|
||||
<QueryErrorState error={projectEnvironmentView.error} size="inline" action="load project environments"
|
||||
onRetry={projectEnvironmentView.retry} retrying={projectEnvironmentView.isFetching} />
|
||||
) : (
|
||||
<p className="text-sm">{storageEnvironmentNames.length ? storageEnvironmentNames.join(", ") : "No available environments"}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{storedExecutionWorkspacePolicy?.storageProviderPluginId
|
||||
? `This project stays on ${providerConfiguration.displayName}.`
|
||||
: "A project moved to a storage plugin stays on that plugin."}
|
||||
{providerConfiguration?.displayName ?? "Core / local"} workspace storage was chosen when this project was created and does not change.
|
||||
</p>
|
||||
{providerConfiguration.error ? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
|
||||
{providerConfiguration?.error && !storagePluginUnavailable
|
||||
? <p role="alert" className="text-xs text-destructive">{providerConfiguration.error}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in new issue
Block a user