mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Choose project storage from compatible environments (#20587)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Projects supply repository files to agent runs through core staging or a storage plugin. > - A storage plugin's default claim silently attached new projects even when their agents used a different environment. > - An environment name or driver type does not establish shared storage access. The existing binding and task-driver contracts do. > - This pull request groups creation choices by that contract and selects storage from the creator's effective environment. > - The benefit is a visible, validated choice and a clear error before an incompatible managed workspace operation. ## Linked Issues or Issue Description Fixes #20459. Related: #20156 covers a separate provider-failure diagnostic. The PR search found no other fix for #20459. ROADMAP.md has no duplicate project-creation change. ## What Changed - Show **Available in** when configured environments require different project storage. Each option names the environments that can use the same storage owner. Hide the selector when all choices are compatible. - Derive compatibility from repository staging and managed binding ownership. Local, SSH, and sandbox environments use core staging. Managed storage requires a registered task driver from the same installed storage plugin. Separate storage plugins remain incompatible. Unknown plugin contracts and the fake sandbox are excluded. - Follow the creating agent's environment, or the person's primary agent, then the instance default and local fallback. Apply managed-only and forced Kubernetes policy. The product has no separate user or company environment-default field today. - Add a company-authorized discovery endpoint and an optional creation `environmentId`. Validate the choice again on save. Store the storage owner without overriding agent run environments. Preserve explicit API storage choices and existing project ownership. - Keep the hidden repository-free private-task project independent of environment availability. Submit the observed storage owner as `expectedStorageProviderPluginId` so a changed contract fails at save without setting a workspace policy. Instance task-isolation defaults stay inherited. - Reject an incompatible managed workspace before a provider operation. Name the storage and selected environment in the error. Add regression tests, API documentation, creation-story fixtures, and minimal corrections to obsolete map text. ## Verification - **102 tests passed across 7 files:** project environment integration, installed storage policy, repository persistence, project environment routes, project creation telemetry routes, OpenAPI, and NewProjectDialog. Run with `node node_modules/vitest/vitest.mjs run server/src/__tests__/project-environments.test.ts server/src/__tests__/project-workspace-provider.integration.test.ts server/src/__tests__/project-repositories-persistence.test.ts server/src/__tests__/project-routes-env.test.ts server/src/__tests__/project-goal-telemetry-routes.test.ts server/src/__tests__/openapi-routes.test.ts ui/src/components/NewProjectDialog.test.tsx --silent`. - The review fixes cover both archived-default private-task cases and changed-storage rejection. With both instance isolation flags enabled, a new core repository project keeps a null policy and resolves to an isolated Git worktree. The complete affected set passes all 102 tests. - The CI query-error policy failure was fixed by using the shared query view. The form and shared query-view suites pass **24 tests**, including cached choices during a transient refetch failure. `node scripts/check-query-error-rendering.mjs --enforce --summary` reports zero findings. - CI exposed stale SDK source hashes and heartbeat fixtures that paired plugin storage with Local. Refreshed exactly four shared-source hashes with the maintained generator (runtime pins unchanged). All **53 SDK privacy/source-proof tests**, including real built SDK output, pass. All **7 corrected managed-storage heartbeat scenarios** pass; the fixtures declare the owning plugin's task environment and substitute transport at the existing execution seam. - Shared and UI TypeScript checks pass. The UI production build passes. `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and diff checks pass. - Tested UI states: compatible choices, incompatible groups, default within a group, manual override, create failure, loading, discovery failure and retry, and an unavailable default. Tests use the production form and existing design tokens. - The integration tests use real isolated PostgreSQL on macOS arm64 with Node 26.12.0. They cover user/agent defaults, storage persistence, policy restrictions, company and authentication denials, and rejection before a provider call. Discovery uses saved configuration and makes no worker or provider requests. - Local verification limits: `pnpm -r typecheck` and `pnpm build` stop in unchanged adapter-utils code because the installed ACPX types lack `inheritProcessEnv`. A direct server check also finds an unchanged AI SDK type-version mismatch. The existing heartbeat test `inherits the instance default environment across companies while preserving explicit agent overrides` times out at line 518 on both this change and unmodified master with the same dependencies. The full `pnpm test:run` attempt passed all six chat shards (1,948 tests), then the Git workspace expiration suite reported 14 failures. I stopped that broad run. Two sampled failures reproduce on unchanged master with the same `unknown_worktree_git_files` refusal; the other expiration failures were not independently compared. A later broad workspace-preparation run also hit workspace validation failures and was stopped; the seven corrected fixture scenarios pass independently. These limits do not establish a full local suite pass. Hosted CI remains required. - Initial missing-package and loopback-denial failures were resolved by repairing isolated dependency links, building the SDK, and running database tests with loopback access. No deployed or live-provider execution is claimed. ## Risks - Creation defaults now follow the effective environment. A plugin's `defaultForProjects` claim alone no longer selects storage. This is the intended behavior change. - Compatibility covers repository project storage. It does not guarantee arbitrary host paths, retained-workspace moves, adapter/image support, credentials, or worker readiness. Matching provider configs cannot prove a cross-plugin storage contract. - A person's primary agent supplies the personal default because there is no separate personal environment setting. An unavailable configured default requires a valid explicit choice or a configuration repair. - No migration runs. Existing projects keep their storage owner. This change does not detach existing managed data or provide a storage migration. - Full local verification has the limits listed above. CI and standard Greptile review are pending. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact serving snapshot and context-window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1c78d023c6
commit
98ce93dd4e
25 files changed
+671
-51
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# Project environments and repository storage
|
||||
|
||||
A repository project belongs to one storage owner. Creation shows **Available in**
|
||||
when the configured environments require more than one owner. Each choice names
|
||||
all environments that use that owner. An environment choice selects storage; it
|
||||
is not an allowlist or an override of an agent's run environment.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
Use the existing workspace execution contract, not environment names, driver
|
||||
names alone, images, operating systems, endpoints, or complete config equality:
|
||||
|
||||
- Local, SSH, and sandbox environments use core repository staging. They can use
|
||||
the same repository project even across driver types and sandbox providers.
|
||||
- Plugin-managed storage belongs to an installed plugin ID. It can be consumed
|
||||
only by that plugin's registered `environment_driver` with `supportsTasks: true`.
|
||||
Its manifest must declare `environment.drivers.register`, `workspaces.manage`,
|
||||
a workspace provider, and the project resource lifecycle.
|
||||
- Two task environments from that installed plugin share its project storage
|
||||
contract, even when their other settings differ. Storage operations are scoped
|
||||
by company, project, and plugin; they do not take an environment configuration.
|
||||
Separate installed storage plugins remain incompatible even when both use the
|
||||
`plugin` driver. Core cannot transfer their managed bindings between them.
|
||||
- Unknown plugin drivers and the probe-only fake sandbox do not establish a
|
||||
compatible project storage contract. They are excluded from creation choices.
|
||||
|
||||
These are project repository compatibility rules. They do not assert that
|
||||
arbitrary local paths exist remotely, that a retained task workspace can switch
|
||||
hosts, or that an adapter, credential, image, or running worker is ready. Existing
|
||||
run-time validation still checks those conditions. Cross-plugin storage sharing
|
||||
would require a new execution/storage contract; matching config fields cannot
|
||||
establish it.
|
||||
|
||||
## Defaults and validation
|
||||
|
||||
For an agent API request, use that agent's default environment. For a person,
|
||||
use their primary agent's default environment when present. Fall back to the
|
||||
instance default, then local execution. There is no separate user or company
|
||||
`defaultEnvironmentId` setting today. Managed-only and forced Kubernetes policies
|
||||
retain their execution restrictions and default redirection.
|
||||
|
||||
`GET /api/companies/:companyId/project-environments` returns environment IDs and
|
||||
names grouped by storage owner, plus the caller's effective default. It excludes
|
||||
archived environments, disabled/uninstalled storage plugins, and managed
|
||||
environments bound only to another company. It does not contact workers or return
|
||||
provider configs, credentials, endpoints, or host paths. A cold worker does not
|
||||
change a durable storage choice.
|
||||
|
||||
`POST /api/companies/:companyId/projects` accepts an optional `environmentId`.
|
||||
The server validates the choice again and stores its storage owner. The project
|
||||
is then available to the compatible environments in that group; no single run
|
||||
environment is pinned. An explicit `executionWorkspacePolicy.storageProviderPluginId`
|
||||
continues to work. The UI sends both the environment and its observed storage
|
||||
owner in `expectedStorageProviderPluginId`, so a changed environment cannot
|
||||
silently select different storage. This guard does not create a workspace-mode
|
||||
policy; instance task-isolation defaults remain inherited. An explicit policy
|
||||
and environment must agree when both are supplied. Without either,
|
||||
storage follows the caller's effective environment. A plugin's
|
||||
`defaultForProjects` claim alone no longer attaches a project to that plugin.
|
||||
|
||||
The hidden repository-free “My private tasks” project is independent of
|
||||
environment/storage availability, so creating a private note never requires
|
||||
repairing an environment.
|
||||
|
||||
An unavailable configured default produces an error and permits an explicit
|
||||
available choice. It does not silently select another storage owner. Existing
|
||||
projects keep their stored owner. This change does not migrate managed storage
|
||||
or detach projects that contain existing work. A managed project run in an
|
||||
incompatible environment fails with `project_environment_incompatible` before a
|
||||
workspace provider operation, with a message naming the storage and environment.
|
||||
@@ -29,8 +29,8 @@ Open `/projects`, create a project, and enter its overview or Issues view.
|
||||
### `repository-settings`
|
||||
|
||||
Open `/projects/:projectId/configuration` and its repository/workspace settings.
|
||||
Workspace storage shows the inherited installed default and allows an explicit
|
||||
provider or Core/local choice. Conflicting defaults remain visible for repair.
|
||||
Workspace storage shows the saved provider or Core/local choice. Plugin-backed
|
||||
projects retain their storage owner.
|
||||
|
||||
### `project-access`
|
||||
|
||||
@@ -48,7 +48,7 @@ Manual: Create a project, add a task from its view, and reload. Verify the task
|
||||
|
||||
### `repository-settings`
|
||||
|
||||
Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover inherited choices, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage.
|
||||
Automated: [provider defaults and API overrides](../../server/src/__tests__/project-workspace-provider.integration.test.ts) and [storage selection UI](../../ui/src/components/ProjectProperties.concurrency.test.tsx) cover saved storage, explicit overrides, and configuration errors. These offline checks do not qualify live provider storage.
|
||||
|
||||
Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows.
|
||||
|
||||
|
||||
@@ -944,6 +944,7 @@ export type {
|
||||
ProjectBudgetSummary,
|
||||
ProjectRepository,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectEnvironmentOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
|
||||
@@ -366,6 +366,7 @@ export type {
|
||||
Project,
|
||||
ProjectRepository,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectEnvironmentOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectAccessMember,
|
||||
ProjectAccessSubjectType,
|
||||
|
||||
@@ -107,6 +107,15 @@ export interface ProjectDiscoveryPage {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectEnvironmentOptions {
|
||||
groups: Array<{
|
||||
storageProviderPluginId: string | null;
|
||||
environments: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
defaultEnvironmentId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspaceProviderConfiguration {
|
||||
source: "project" | "core";
|
||||
pluginId: string | null;
|
||||
|
||||
@@ -126,6 +126,8 @@ const projectFields = {
|
||||
export const createProjectSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(255).optional(),
|
||||
...projectFields,
|
||||
environmentId: z.string().guid().optional(),
|
||||
expectedStorageProviderPluginId: z.string().guid().nullable().optional(),
|
||||
workspace: createProjectWorkspaceSchema.optional(),
|
||||
repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(),
|
||||
repositoryUrls: z.array(z.string().url().max(2000)).max(100).optional(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
|
||||
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, executionWorkspaces, environments, heartbeatRuns, issues, plugins, projects } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
@@ -120,9 +120,14 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
const f = await seed();
|
||||
const storage = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, workspaceProvider: {
|
||||
...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" },
|
||||
} } }).where(eq(plugins.id, storage.pluginId));
|
||||
const [environment] = await db.insert(environments).values({ name: `Storage tasks ${storage.pluginId}`, driver: "plugin", status: "active",
|
||||
config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson,
|
||||
capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"],
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
|
||||
workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
|
||||
} }).where(eq(plugins.id, storage.pluginId));
|
||||
await db.update(agents).set({ defaultEnvironmentId: environment.id }).where(eq(agents.id, f.agentId));
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, storageProviderPluginId: storage.pluginId } }).where(eq(projects.id, storage.input.projectId));
|
||||
await db.update(issues).set({ projectId: storage.input.projectId, projectWorkspaceId: storage.input.projectWorkspaceId,
|
||||
executionWorkspaceSettings: settings,
|
||||
@@ -132,6 +137,9 @@ describePostgres("heartbeat session preparation through adapter dispatch", () =>
|
||||
const createOrchestrator = environmentOrchestrator.environmentRunOrchestrator;
|
||||
const realization = vi.spyOn(environmentOrchestrator, "environmentRunOrchestrator").mockImplementation((...args) => ({
|
||||
...createOrchestrator(...args),
|
||||
// These tests dispatch a synthetic adapter; substitute only the provider
|
||||
// transport while preserving the configured storage/task-driver identity.
|
||||
acquireForRun: async input => createOrchestrator(...args).acquireForRun({ ...input, selectedEnvironmentId: input.localEnvironmentId }),
|
||||
realizeForRun: async input => {
|
||||
input.executionWorkspace.cwd = cwd;
|
||||
return { lease: input.lease, persistedExecutionWorkspace: input.persistedExecutionWorkspace,
|
||||
|
||||
@@ -1300,6 +1300,17 @@ describe("openapi routes", () => {
|
||||
expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" });
|
||||
});
|
||||
|
||||
it("documents project environment compatibility and creation selection", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const operation = spec.paths["/api/companies/{companyId}/project-environments"].get;
|
||||
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
|
||||
expect(operation.responses["403"]).toBeDefined();
|
||||
expect(operation.responses["200"].content["application/json"].schema.properties).toHaveProperty("defaultEnvironmentId");
|
||||
const creation = spec.paths["/api/companies/{companyId}/projects"].post;
|
||||
expect(creation.requestBody.content["application/json"].schema.properties.environmentId).toMatchObject({ type: "string", format: "uuid" });
|
||||
expect(creation.requestBody.content["application/json"].schema.properties.expectedStorageProviderPluginId).toMatchObject({ type: "string", nullable: true });
|
||||
});
|
||||
|
||||
it("documents board-only repository discovery and selection", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { agents, authUsers, companyMemberships, builtInManagedResources, companies, createDb, environments, instanceSettings, plugins, userCompanyPreferences } from "@paperclipai/db";
|
||||
import { projectEnvironmentOptions, withDefaultProjectStorage } from "../services/project-environments.js";
|
||||
import { assertProjectStorageEnvironment, projectStorageForEnvironment } from "../services/project-workspace-provider.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { applyDefaultIsolatedExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType } from "../services/execution-workspace-policy.js";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { preparePluginWorkspace } from "../services/plugin-workspaces.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { createPluginWorkspaceFixture } from "./helpers/plugin-workspaces.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("project environment compatibility", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("project-environments");
|
||||
db = createDb(database.connectionString);
|
||||
}, 120_000);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
beforeEach(async () => { await db.execute(sql`truncate companies, plugins, environments, instance_settings cascade`); });
|
||||
|
||||
async function fixture() {
|
||||
const f = await createPluginWorkspaceFixture(db);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
|
||||
plugin.manifestJson = { ...plugin.manifestJson,
|
||||
capabilities: [...plugin.manifestJson.capabilities, "environment.drivers.register"],
|
||||
environmentDrivers: [{ driverKey: "tasks", supportsTasks: true, displayName: "Computers", configSchema: { type: "object", properties: {} } }],
|
||||
workspaceProvider: { ...plugin.manifestJson.workspaceProvider!, defaultForProjects: { mode: "isolated_workspace", strategy: "git_worktree" } },
|
||||
};
|
||||
await db.update(plugins).set({ manifestJson: plugin.manifestJson }).where(eq(plugins.id, plugin.id));
|
||||
const [computer, local, sandbox, ssh] = await db.insert(environments).values([
|
||||
{ name: "Computers", driver: "plugin", config: { pluginKey: plugin.pluginKey, driverKey: "tasks", driverConfig: {} } },
|
||||
{ name: "Local", driver: "local" },
|
||||
{ name: "Sandbox", driver: "sandbox", config: { provider: "daytona" } },
|
||||
{ name: "Build host", driver: "ssh", config: { host: "build.example.com" } },
|
||||
]).returning();
|
||||
await db.insert(instanceSettings).values({ defaultEnvironmentId: sandbox.id });
|
||||
return { ...f, plugin, computer, local, sandbox, ssh };
|
||||
}
|
||||
|
||||
it("groups by repository storage, across driver types and provider configurations", async () => {
|
||||
const f = await fixture();
|
||||
const [otherComputer] = await db.insert(environments).values({ name: "Other computers", driver: "plugin",
|
||||
config: { ...f.computer.config, driverConfig: { profile: "other" } } }).returning();
|
||||
const [otherSandbox] = await db.insert(environments).values({ name: "Other sandbox", driver: "sandbox", config: { provider: "modal" } }).returning();
|
||||
const options = await projectEnvironmentOptions(db, f.companyId);
|
||||
expect(options.error).toBeNull();
|
||||
expect(options.defaultEnvironmentId).toBe(f.sandbox.id);
|
||||
expect(options.groups).toHaveLength(2);
|
||||
expect(options.groups.find(group => group.storageProviderPluginId === null)?.environments.map(value => value.id).sort())
|
||||
.toEqual([f.local.id, f.sandbox.id, f.ssh.id, otherSandbox.id].sort());
|
||||
expect(options.groups.find(group => group.storageProviderPluginId === f.pluginId)?.environments.map(value => value.id).sort())
|
||||
.toEqual([f.computer.id, otherComputer.id].sort());
|
||||
expect(JSON.stringify(options)).not.toContain("driverConfig");
|
||||
expect(JSON.stringify(options)).not.toContain("build.example.com");
|
||||
});
|
||||
|
||||
it("does not equate two plugin drivers with different storage owners", async () => {
|
||||
const f = await fixture();
|
||||
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
await db.update(plugins).set({ manifestJson: { ...f.plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId));
|
||||
await db.insert(environments).values({ name: "Separate computers", driver: "plugin", config: { pluginKey: `test.${other.pluginId}`, driverKey: "tasks" } });
|
||||
expect((await projectEnvironmentOptions(db, f.companyId)).groups).toHaveLength(3);
|
||||
expect(projectStorageForEnvironment({ driver: "plugin", config: { pluginKey: f.plugin.pluginKey, driverKey: "unknown" } }, [f.plugin])).toBeNull();
|
||||
expect(projectStorageForEnvironment({ driver: "sandbox", config: { provider: "fake" } }, [f.plugin])).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the creating agent, the user's primary agent, then the instance default", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId));
|
||||
await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId: "person", primaryAgentId: f.input.agentId });
|
||||
expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.sandbox.id);
|
||||
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.computer.id);
|
||||
expect((await projectEnvironmentOptions(db, f.companyId, { agentId: f.input.agentId })).defaultEnvironmentId).toBe(f.computer.id);
|
||||
expect(await withDefaultProjectStorage(db, f.companyId, null)).toBeNull();
|
||||
expect(await withDefaultProjectStorage(db, f.companyId, null, { userId: "person" })).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
await db.update(agents).set({ defaultEnvironmentId: null }).where(eq(agents.id, f.input.agentId));
|
||||
expect((await projectEnvironmentOptions(db, f.companyId, { userId: "person" })).defaultEnvironmentId).toBe(f.sandbox.id);
|
||||
await db.update(instanceSettings).set({ defaultEnvironmentId: null });
|
||||
expect((await projectEnvironmentOptions(db, f.companyId)).defaultEnvironmentId).toBe(f.local.id);
|
||||
});
|
||||
|
||||
it("pins the selected storage, preserves explicit API choices, and rejects conflicting choices", async () => {
|
||||
const f = await fixture();
|
||||
const svc = projectService(db);
|
||||
const sandboxProject = await svc.createWithRepositories(f.companyId, { name: "Sandbox project" }, [
|
||||
{ id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] },
|
||||
]);
|
||||
expect(sandboxProject.workspaceProvider?.pluginId).toBeNull();
|
||||
const computerProject = await svc.create(f.companyId, { name: "Computer project" }, { environmentId: f.computer.id });
|
||||
expect(computerProject.workspaceProvider?.pluginId).toBe(f.pluginId);
|
||||
const explicit = { enabled: true, storageProviderPluginId: null };
|
||||
expect(await withDefaultProjectStorage(db, f.companyId, explicit)).toEqual(explicit);
|
||||
await expect(withDefaultProjectStorage(db, f.companyId, explicit, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
|
||||
await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
|
||||
expect((await svc.getById(sandboxProject.id))?.workspaceProvider?.pluginId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves inherited task isolation when checking the observed core storage owner", async () => {
|
||||
const f = await fixture();
|
||||
const { experimental: settings } = await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: true });
|
||||
const project = await projectService(db).createWithRepositories(f.companyId, { name: "Inherited isolation" }, [
|
||||
{ id: "42", fullName: "example/repo", url: "https://github.com/example/repo", connections: [] },
|
||||
], { environmentId: f.sandbox.id, expectedStorageProviderPluginId: null });
|
||||
expect(project.executionWorkspacePolicy).toBeNull();
|
||||
const projectPolicy = applyDefaultIsolatedExecutionWorkspacePolicy({
|
||||
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(project.executionWorkspacePolicy), settings.enableIsolatedWorkspaces),
|
||||
defaultIsolatedWorkspacesEnabled: settings.enableIsolatedWorkspacesByDefault,
|
||||
hasProjectWorkspace: project.workspaces.length > 0,
|
||||
});
|
||||
const mode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings: null, legacyUseProjectWorkspace: null });
|
||||
const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, issueSettings: null, mode, legacyUseProjectWorkspace: null });
|
||||
expect(mode).toBe("isolated_workspace");
|
||||
expect(resolveEffectiveWorkspaceStrategyType(mode, config)).toBe("git_worktree");
|
||||
});
|
||||
|
||||
it("fails before a provider operation when managed storage cannot serve the selected environment", async () => {
|
||||
const f = await fixture();
|
||||
expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.sandbox, driver: "sandbox" })).toThrow('does not support the environment "Sandbox"');
|
||||
await expect(preparePluginWorkspace(db, f.workers, { ...f.input, environment: { ...f.sandbox, driver: "sandbox" } }))
|
||||
.rejects.toMatchObject({ status: 409, details: { code: "project_environment_incompatible", retryable: false } });
|
||||
expect(f.calls).toHaveLength(0);
|
||||
expect(() => assertProjectStorageEnvironment(f.plugin, { ...f.computer, driver: "plugin" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("excludes archived, company-inaccessible, and disabled plugin environments", async () => {
|
||||
const f = await fixture();
|
||||
const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning();
|
||||
await db.insert(builtInManagedResources).values({ companyId: otherCompany.id, bundleKey: "test", resourceKind: "environment",
|
||||
resourceKey: "environment", resourceId: f.ssh.id, stockVersion: "1", stockHash: "test" });
|
||||
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.local.id));
|
||||
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, f.pluginId));
|
||||
const options = await projectEnvironmentOptions(db, f.companyId);
|
||||
expect(options.groups.flatMap(group => group.environments).map(value => value.id)).toEqual([f.sandbox.id]);
|
||||
for (const environmentId of [f.local.id, f.ssh.id, f.computer.id, randomUUID()]) {
|
||||
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId })).rejects.toMatchObject({ status: 409 });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a cold worker's storage identity, but refuses an archived default", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
|
||||
await db.update(plugins).set({ status: "error" }).where(eq(plugins.id, f.pluginId));
|
||||
expect(await withDefaultProjectStorage(db, f.companyId, null)).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id));
|
||||
expect((await projectEnvironmentOptions(db, f.companyId)).error).toBeTruthy();
|
||||
await expect(withDefaultProjectStorage(db, f.companyId, null)).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it.each(["instance", "primary"] as const)("creates repository-free private tasks despite an archived %s default", async source => {
|
||||
const f = await fixture();
|
||||
const userId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Person", email: `${userId}@example.com`, createdAt: now, updatedAt: now });
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId,
|
||||
status: "active", membershipRole: "operator" });
|
||||
await db.update(environments).set({ status: "archived" }).where(eq(environments.id, f.computer.id));
|
||||
if (source === "instance") await db.update(instanceSettings).set({ defaultEnvironmentId: f.computer.id });
|
||||
else {
|
||||
await db.update(agents).set({ defaultEnvironmentId: f.computer.id }).where(eq(agents.id, f.input.agentId));
|
||||
await db.insert(userCompanyPreferences).values({ companyId: f.companyId, userId, primaryAgentId: f.input.agentId });
|
||||
}
|
||||
const task = await issueService(db).create(f.companyId, { title: "Private note", visibility: "private", createdByUserId: userId });
|
||||
expect(task.visibility).toBe("private");
|
||||
const project = await projectService(db).getById(task.projectId!);
|
||||
expect(project?.name).toBe("My private tasks");
|
||||
expect(project?.executionWorkspacePolicy).toBeNull();
|
||||
expect(project?.workspaces).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors managed-only and forced Kubernetes execution policy", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(environments).set({ metadata: { managedByPaperclip: true, managedKubernetesSandbox: true } }).where(eq(environments.id, f.sandbox.id));
|
||||
await db.update(instanceSettings).set({ defaultEnvironmentId: f.local.id, experimental: { enableManagedSandboxOnly: true } });
|
||||
const managed = await projectEnvironmentOptions(db, f.companyId);
|
||||
expect(managed.defaultEnvironmentId).toBe(f.sandbox.id);
|
||||
expect(managed.groups.flatMap(group => group.environments).map(value => value.id)).not.toContain(f.local.id);
|
||||
await db.update(instanceSettings).set({ general: { executionMode: "kubernetes" } });
|
||||
const forced = await projectEnvironmentOptions(db, f.companyId);
|
||||
expect(forced.groups).toEqual([{ storageProviderPluginId: null, environments: [{ id: f.sandbox.id, name: f.sandbox.name }] }]);
|
||||
await expect(withDefaultProjectStorage(db, f.companyId, null, { environmentId: f.computer.id })).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("exposes safe groups and saves a validated choice through the normal API", async () => {
|
||||
const f = await fixture();
|
||||
function app(actor: Express.Request["actor"]) {
|
||||
const result = express(); result.use(express.json());
|
||||
result.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
result.use("/api", projectRoutes(db)); result.use(errorHandler); return result;
|
||||
}
|
||||
const board = app({ type: "board", userId: "person", source: "session", companyIds: [f.companyId] });
|
||||
const url = `/api/companies/${f.companyId}`;
|
||||
const options = await request(board).get(`${url}/project-environments`);
|
||||
expect(options.status).toBe(200);
|
||||
expect(options.body.groups).toHaveLength(2);
|
||||
const created = await request(board).post(`${url}/projects`).send({ name: "Selected", environmentId: f.computer.id });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
const denied = app({ type: "agent", agentId: f.input.agentId, companyId: randomUUID() });
|
||||
expect((await request(denied).get(`${url}/project-environments`)).status).toBe(403);
|
||||
expect((await request(app({ type: "none" })).get(`${url}/project-environments`)).status).toBe(401);
|
||||
const agent = app({ type: "agent", agentId: f.input.agentId, companyId: f.companyId });
|
||||
expect((await request(agent).get(`${url}/project-environments`)).status).toBe(200);
|
||||
await db.update(environments).set({ driver: "plugin", config: f.computer.config }).where(eq(environments.id, f.ssh.id));
|
||||
const changed = await request(board).post(`${url}/projects`).send({ name: "Changed storage", environmentId: f.ssh.id,
|
||||
expectedStorageProviderPluginId: null });
|
||||
expect(changed.status).toBe(409);
|
||||
expect(changed.body.details.code).toBe("project_environment_incompatible");
|
||||
const invalid = await request(board).post(`${url}/projects`).send({ name: "Invalid", environmentId: randomUUID() });
|
||||
expect(invalid.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -208,6 +208,7 @@ describe("project env routes", () => {
|
||||
expect(mockProjectService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ env: normalizedEnv }),
|
||||
expect.objectContaining({ userId: "board-user" }),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -36,13 +36,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
return f;
|
||||
}
|
||||
|
||||
it("attaches a new project to the installed default and leaves existing projects where they are", async () => {
|
||||
it("keeps a new project on core storage when no environment selects the installed plugin", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(projects).set({ executionWorkspacePolicy: null }).where(eq(projects.id, f.input.projectId));
|
||||
const svc = projectService(db);
|
||||
const created = await svc.create(f.companyId, { name: "New project" });
|
||||
expect(created.executionWorkspacePolicy).toMatchObject({ enabled: true, storageProviderPluginId: f.pluginId });
|
||||
expect(created.workspaceProvider).toMatchObject({ source: "project", pluginId: f.pluginId });
|
||||
expect(created.executionWorkspacePolicy).toBeNull();
|
||||
expect(created.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
|
||||
const existing = await svc.getById(f.input.projectId);
|
||||
expect(existing!.executionWorkspacePolicy).toBeNull();
|
||||
expect(existing!.workspaceProvider).toMatchObject({ source: "core", pluginId: null });
|
||||
@@ -73,10 +73,10 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
.toMatchObject({ source: "project", pluginId: other.pluginId });
|
||||
});
|
||||
|
||||
it("keeps a personal project on the installed default", async () => {
|
||||
it("does not attach a personal project based only on a plugin default claim", async () => {
|
||||
const f = await fixture();
|
||||
const project = await ensurePersonalPrivateProject(db, f.companyId, "user-one");
|
||||
expect(project.executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
expect(project.executionWorkspacePolicy).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["create", "import"] as const)("%s leaves an unspecified task mode inherited from the effective provider", async method => {
|
||||
@@ -153,7 +153,7 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
await db.insert(pluginCompanySettings).values({ companyId: other.id, pluginId: f.pluginId, enabled: true });
|
||||
const svc = projectService(db);
|
||||
expect((await svc.create(f.companyId, { name: "Disabled here" })).executionWorkspacePolicy).toBeNull();
|
||||
expect((await svc.create(other.id, { name: "Enabled here" })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
expect((await svc.create(other.id, { name: "Enabled here", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).executionWorkspacePolicy).toMatchObject({ storageProviderPluginId: f.pluginId });
|
||||
await expect(effectiveProjectWorkspacePolicy(db, f.companyId, { enabled: true, storageProviderPluginId: f.pluginId }, true))
|
||||
.rejects.toThrow("unavailable");
|
||||
});
|
||||
@@ -175,13 +175,13 @@ describe.skipIf(!support.supported)("installed project workspace defaults", () =
|
||||
expect(await db.select().from(executionWorkspaces)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("asks for a choice when creating a project with conflicting defaults, and leaves existing projects working", async () => {
|
||||
it("ignores conflicting storage default claims without a matching environment and preserves explicit choices", async () => {
|
||||
const f = await fixture();
|
||||
const other = await createPluginWorkspaceFixture(db, f.companyId);
|
||||
const [plugin] = await db.select().from(plugins).where(eq(plugins.id, f.pluginId));
|
||||
await db.update(plugins).set({ manifestJson: { ...plugin.manifestJson, id: `test.${other.pluginId}` } }).where(eq(plugins.id, other.pluginId));
|
||||
const svc = projectService(db);
|
||||
await expect(svc.create(f.companyId, { name: "Ambiguous" })).rejects.toThrow("Multiple workspace storage plugins");
|
||||
expect((await svc.create(f.companyId, { name: "Core" })).workspaceProvider).toMatchObject({ pluginId: null, error: null });
|
||||
expect((await svc.create(f.companyId, { name: "Chosen", executionWorkspacePolicy: { enabled: true, storageProviderPluginId: f.pluginId } })).workspaceProvider)
|
||||
.toMatchObject({ pluginId: f.pluginId, error: null });
|
||||
expect((await svc.getById(f.input.projectId))!.workspaceProvider).toMatchObject({ pluginId: f.pluginId, error: null });
|
||||
|
||||
@@ -5126,6 +5126,26 @@ registry.registerPath({
|
||||
|
||||
// ─── Projects ────────────────────────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/project-environments",
|
||||
tags: ["projects"],
|
||||
summary: "List compatible project environments and the caller's default",
|
||||
description: "Groups configured environments by repository storage owner. Returns names and IDs without provider configuration. Creation revalidates environmentId and stores its storage owner without overriding agent execution environments.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(z.object({
|
||||
groups: z.array(z.object({
|
||||
storageProviderPluginId: z.string().uuid().nullable(),
|
||||
environments: z.array(z.object({ id: z.string().uuid(), name: z.string() })),
|
||||
})),
|
||||
defaultEnvironmentId: z.string().uuid().nullable(),
|
||||
error: z.string().nullable(),
|
||||
})),
|
||||
401: r.unauthorized, 403: r.forbidden,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/project-repositories",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectEnvironmentOptions } from "../services/project-environments.js";
|
||||
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
|
||||
import { skillAccessRequestContext } from "../services/skill-access.js";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -375,15 +376,26 @@ export function projectRoutes(db: Db) {
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/project-environments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await projectEnvironmentOptions(db, companyId, {
|
||||
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
}));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
type CreateProjectPayload = Parameters<typeof svc.create>[1] & {
|
||||
workspace?: Parameters<typeof svc.createWorkspace>[1];
|
||||
repositoryIds?: string[];
|
||||
environmentId?: string;
|
||||
expectedStorageProviderPluginId?: string | null;
|
||||
};
|
||||
|
||||
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
|
||||
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, environmentId, expectedStorageProviderPluginId, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
|
||||
const receiptOnly = req.get("X-Paperclip-Receipt-Only") === "true";
|
||||
if (receiptOnly && !idempotencyKey) throw unprocessable("Project receipt recovery requires an idempotency key");
|
||||
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
|
||||
@@ -412,7 +424,7 @@ export function projectRoutes(db: Db) {
|
||||
const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl);
|
||||
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
|
||||
const actor = getActorInfo(req);
|
||||
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex");
|
||||
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls, environmentId, expectedStorageProviderPluginId })).digest("hex");
|
||||
const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null;
|
||||
const result = await db.transaction(async (tx) => {
|
||||
if (receiptKey) {
|
||||
@@ -457,7 +469,13 @@ export function projectRoutes(db: Db) {
|
||||
if (receiptOnly) return null;
|
||||
if (runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
|
||||
const service = projectService(tx as unknown as Db);
|
||||
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
|
||||
const environment = {
|
||||
environmentId,
|
||||
expectedStorageProviderPluginId,
|
||||
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
|
||||
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
};
|
||||
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories, environment) : await service.create(companyId, projectData, environment);
|
||||
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db);
|
||||
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
|
||||
const registeredUrls: typeof urlRepositories = [];
|
||||
|
||||
@@ -265,7 +265,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
driver: "sandbox" as const, config: { provider: "daytona" } };
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner" }).where(eq(agents.id, input.agent.id));
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.environment.selectedEnvironmentForConfig = environment;
|
||||
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
|
||||
input.environment.selectedEnvironmentId = environment.id;
|
||||
expect(input.environment.resolvedInstanceSettings.experimental.remoteWorkspaceStorageEnabled).toBe(true);
|
||||
input.policy.requestedExecutionWorkspaceMode = "agent_default";
|
||||
@@ -964,7 +964,13 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
const pluginKey = `test.managed-storage-${fromDefault}-${defaultMode}-${fresh}`;
|
||||
const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: "test-storage", version: "1.0.0", apiVersion: 1, status: "ready", categories: ["workspace"],
|
||||
manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Storage", description: "Storage", author: "Test", categories: ["workspace"],
|
||||
workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe"], resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
workspaceProvider: { freshWorkspaceVersion: 1, modes: ["isolated_workspace", "operator_branch"], strategies: ["git_worktree"], ...(fromDefault ? { defaultForProjects: { mode: defaultMode, strategy: "git_worktree" as const } } : {}) }, capabilities: ["workspaces.manage", "events.subscribe", "environment.drivers.register"],
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }],
|
||||
resourceLifecycle: ["project"], entrypoints: { worker: "./worker.js" } } }).returning();
|
||||
const [environment] = await db.insert(environments).values({ name: `Storage tasks ${pluginKey}`, driver: "plugin", status: "active",
|
||||
config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
|
||||
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
|
||||
input.environment.selectedEnvironmentId = environment.id;
|
||||
const savedPolicy = fromDefault ? { enabled: true, storageProviderPluginId: plugin.id } : { enabled: true, storageProviderPluginId: plugin.id, defaultMode: "isolated_workspace" as const,
|
||||
workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", branchTemplate: "test-{{issue.identifier}}" } };
|
||||
await db.update(projects).set({ executionWorkspacePolicy: savedPolicy }).where(eq(projects.id, issue.projectId!));
|
||||
@@ -989,7 +995,8 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
const acquire = input.services.envOrchestrator.acquireForRun;
|
||||
vi.spyOn(input.services.envOrchestrator, "acquireForRun").mockImplementation(async args => {
|
||||
sequence.push("acquire"); expect(args.persistedExecutionWorkspace).toMatchObject({ providerType: "plugin", cwd: null });
|
||||
return acquire(args);
|
||||
// Acquisition is a transport stub: this boundary test stops at handoff.
|
||||
return acquire({ ...args, selectedEnvironmentId: args.localEnvironmentId });
|
||||
});
|
||||
vi.spyOn(input.services.envOrchestrator, "realizeForRun").mockImplementation(async args => {
|
||||
expect(args.executionWorkspace.providerBinding?.workspaceRef).toBe("provider:checkout");
|
||||
@@ -1090,7 +1097,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
environmentDrivers: [{ driverKey: "tasks", displayName: "Task VM", configSchema: { type: "object" }, supportsTasks: true, freshWorkspaceVersion: 1 }] } });
|
||||
const environment = await environmentService(db).create({ name: `Task computer ${issue.id}`, driver: "plugin", status: "active", envVars: {}, config: { pluginKey, driverKey: "tasks" } });
|
||||
input.agent = { ...input.agent, adapterType: "paperclip_runner", defaultEnvironmentId: environment.id };
|
||||
input.environment.selectedEnvironmentForConfig = environment;
|
||||
input.environment.selectedEnvironmentForConfig = await environmentService(db).getById(environment.id);
|
||||
input.policy.requestedExecutionWorkspaceMode = input.policy.effectiveExecutionWorkspaceMode = "isolated_workspace";
|
||||
input.task.freshWorkspace = { mode: "fresh_isolated_workspace", retryIntentId: input.run.id, sourceRunId: input.run.id, retainedSourceIds: [] };
|
||||
const stopped = new Error("Provider directory prepared");
|
||||
|
||||
@@ -554,6 +554,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
existingWorkspaceId: providerFresh ? providerFresh.previousWorkspaceId : reusableExistingExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null,
|
||||
...(providerFresh ? { fresh: providerFresh, expectedBindingRevision: previousProviderBindingRevision } : {}),
|
||||
selectedProviderPluginId: projectExecutionWorkspacePolicy?.storageProviderPluginId ?? null,
|
||||
environment: selectedEnvironmentForConfig,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
strategy: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, mergedConfig),
|
||||
branchMode: typeof parseObject(mergedConfig.workspaceStrategy).existingBranch === "string" ? "existing" : "create",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { assertProjectStorageEnvironment, effectiveProjectWorkspacePolicy, isWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { releaseProviderRecoveryTransfers, stageProviderFreshWorkspace } from "./provider-fresh-workspace.js";
|
||||
import { retireAbandonedPluginWorkspaceReservation } from "./plugin-workspace-reservations.js";
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
@@ -198,6 +198,7 @@ export async function preparePluginWorkspace(
|
||||
mode: string;
|
||||
strategy: string;
|
||||
selectedProviderPluginId?: string | null;
|
||||
environment?: Pick<import("@paperclipai/shared").Environment, "driver" | "config" | "name"> | null;
|
||||
branchMode?: "create" | "existing";
|
||||
branchName?: string | null;
|
||||
baseRef?: string | null;
|
||||
@@ -276,6 +277,7 @@ export async function preparePluginWorkspace(
|
||||
"An existing local workspace cannot change storage providers",
|
||||
);
|
||||
const selectedProvider = await provider(db, workers, input.companyId, pluginId, true);
|
||||
if (input.environment) assertProjectStorageEnvironment(selectedProvider, input.environment);
|
||||
const declaration = selectedProvider.manifestJson.workspaceProvider!;
|
||||
if (input.fresh && declaration.freshWorkspaceVersion !== 1)
|
||||
throw conflict("A fresh attempt is unavailable for this workspace storage provider. The existing workspace is retained.", { reason: "fresh_workspace_storage_unsupported" });
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { agents, builtInManagedResources, userCompanyPreferences, type Db } from "@paperclipai/db";
|
||||
import type { ProjectEnvironmentOptions } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
import { environmentService } from "./environments.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { listProjectWorkspaceProviders, projectStorageForEnvironment } from "./project-workspace-provider.js";
|
||||
|
||||
export interface ProjectCreationEnvironment {
|
||||
userId?: string | null;
|
||||
agentId?: string | null;
|
||||
environmentId?: string;
|
||||
expectedStorageProviderPluginId?: string | null;
|
||||
}
|
||||
|
||||
export async function projectEnvironmentOptions(db: Db, companyId: string, actor: ProjectCreationEnvironment = {}): Promise<ProjectEnvironmentOptions> {
|
||||
const svc = environmentService(db);
|
||||
const [settings, environments, providers, bindings] = await Promise.all([
|
||||
instanceSettingsService(db).get(),
|
||||
svc.list({ status: "active" }),
|
||||
listProjectWorkspaceProviders(db, companyId),
|
||||
db.select({ environmentId: builtInManagedResources.resourceId, companyId: builtInManagedResources.companyId })
|
||||
.from(builtInManagedResources).where(eq(builtInManagedResources.resourceKind, "environment")),
|
||||
]);
|
||||
let preferredEnvironmentId = settings.defaultEnvironmentId;
|
||||
if (actor.agentId || actor.userId) {
|
||||
const candidates = db.select({ environmentId: agents.defaultEnvironmentId }).from(agents);
|
||||
const [agent] = actor.agentId
|
||||
? await candidates.where(and(eq(agents.companyId, companyId), eq(agents.id, actor.agentId)))
|
||||
: await candidates.innerJoin(userCompanyPreferences, and(eq(userCompanyPreferences.primaryAgentId, agents.id),
|
||||
eq(userCompanyPreferences.companyId, companyId), eq(userCompanyPreferences.userId, actor.userId!)))
|
||||
.where(and(eq(agents.companyId, companyId), notInArray(agents.status, ["terminated", "pending_approval"])));
|
||||
preferredEnvironmentId = agent?.environmentId ?? preferredEnvironmentId;
|
||||
}
|
||||
let preferred = environments.find(environment => environment.id === preferredEnvironmentId)
|
||||
?? (!preferredEnvironmentId ? environments.find(environment => environment.driver === "local") : undefined);
|
||||
const forcedKubernetes = settings.general.executionMode === "kubernetes";
|
||||
const managedOnly = settings.experimental.enableManagedSandboxOnly === true;
|
||||
if (forcedKubernetes) {
|
||||
preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedKubernetesSandbox === true);
|
||||
} else if (managedOnly && (!preferred || preferred.driver === "local")) {
|
||||
preferred = environments.find(environment => environment.driver === "sandbox" && environment.metadata?.managedByPaperclip === true);
|
||||
}
|
||||
|
||||
const groups: ProjectEnvironmentOptions["groups"] = [];
|
||||
for (const environment of environments) {
|
||||
const owners = bindings.filter(binding => binding.environmentId === environment.id);
|
||||
if (owners.length && !owners.some(binding => binding.companyId === companyId)) continue;
|
||||
if (forcedKubernetes && environment.id !== preferred?.id) continue;
|
||||
if (managedOnly && environment.driver === "local") continue;
|
||||
const storage = projectStorageForEnvironment(environment, providers);
|
||||
if (!storage) continue;
|
||||
let group = groups.find(value => value.storageProviderPluginId === storage.pluginId);
|
||||
if (!group) {
|
||||
group = { storageProviderPluginId: storage.pluginId, environments: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.environments.push({ id: environment.id, name: environment.name });
|
||||
}
|
||||
for (const group of groups) group.environments.sort((a, b) => a.name.localeCompare(b.name));
|
||||
groups.sort((a, b) => a.environments[0].name.localeCompare(b.environments[0].name));
|
||||
const defaultAvailable = preferred && groups.some(group => group.environments.some(environment => environment.id === preferred.id));
|
||||
return {
|
||||
groups,
|
||||
defaultEnvironmentId: defaultAvailable ? preferred?.id ?? null : null,
|
||||
error: !defaultAvailable && (preferredEnvironmentId || forcedKubernetes || managedOnly || environments.length > 0)
|
||||
? "The default environment cannot use project workspace storage. Select an available environment or repair the default environment configuration."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function withDefaultProjectStorage(db: Db, companyId: string, rawPolicy: unknown, actor: ProjectCreationEnvironment = {}) {
|
||||
const policy = parseProjectExecutionWorkspacePolicy(rawPolicy);
|
||||
if (policy?.storageProviderPluginId !== undefined && !actor.environmentId && actor.expectedStorageProviderPluginId === undefined) return rawPolicy;
|
||||
const options = await projectEnvironmentOptions(db, companyId, actor);
|
||||
const environmentId = actor.environmentId ?? options.defaultEnvironmentId;
|
||||
const group = options.groups.find(value => value.environments.some(environment => environment.id === environmentId));
|
||||
if (actor.environmentId && !group) throw conflict("The selected environment is unavailable for this project. Select an available environment.", { code: "project_environment_unavailable" });
|
||||
if (!actor.environmentId && options.error) throw conflict(options.error, { code: "project_environment_unavailable" });
|
||||
const pluginId = group?.storageProviderPluginId ?? null;
|
||||
if (actor.expectedStorageProviderPluginId !== undefined && actor.expectedStorageProviderPluginId !== pluginId) {
|
||||
throw conflict("The environment's project storage changed. Review the available environments and retry.", { code: "project_environment_incompatible" });
|
||||
}
|
||||
if (policy?.storageProviderPluginId !== undefined && policy.storageProviderPluginId !== pluginId) {
|
||||
throw conflict("The selected environment cannot use the requested project workspace storage.", { code: "project_environment_incompatible" });
|
||||
}
|
||||
return pluginId || policy?.storageProviderPluginId !== undefined
|
||||
? { enabled: true, ...policy, storageProviderPluginId: pluginId }
|
||||
: rawPolicy;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { pluginCompanySettings, plugins, type Db } from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
|
||||
import type { Environment, PaperclipPluginManifestV1, ProjectExecutionWorkspacePolicy, ProjectWorkspaceProviderConfiguration } from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { conflict } from "../errors.js";
|
||||
|
||||
@@ -68,19 +68,33 @@ export function sourcelessProjectWorkspacePolicy(rawPolicy: unknown): ProjectExe
|
||||
return Object.keys(settings).some(key => key !== "enabled") ? settings : null;
|
||||
}
|
||||
|
||||
export async function withDefaultProjectStorage(db: ProviderDb, companyId: string, rawPolicy: unknown) {
|
||||
if (parseProjectExecutionWorkspacePolicy(rawPolicy)?.storageProviderPluginId !== undefined) return rawPolicy;
|
||||
const defaults = (await listProjectWorkspaceProviders(db, companyId))
|
||||
.filter(plugin => plugin.manifestJson.workspaceProvider?.defaultForProjects);
|
||||
if (defaults.length > 1) {
|
||||
throw conflict(`Multiple workspace storage plugins claim the project default: ${defaults.map(plugin => plugin.manifestJson.displayName).sort().join(", ")}. Select a storage provider in project configuration or disable a conflicting provider.`);
|
||||
}
|
||||
if (!defaults[0]) return rawPolicy;
|
||||
return { enabled: true, ...(rawPolicy as Record<string, unknown> | null), storageProviderPluginId: defaults[0].id };
|
||||
}
|
||||
|
||||
export async function effectiveProjectWorkspacePolicy(db: ProviderDb, companyId: string, rawPolicy: unknown, hasGitSource: boolean) {
|
||||
const configuration = resolveProjectWorkspaceProvider(rawPolicy, await listProjectWorkspaceProviders(db, companyId), hasGitSource);
|
||||
if (configuration.error) throw conflict(configuration.error);
|
||||
return configuration.effectivePolicy;
|
||||
}
|
||||
|
||||
export function projectStorageForEnvironment(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
providers: Array<Pick<Provider, "id" | "pluginKey" | "manifestJson">>,
|
||||
): { pluginId: string | null } | null {
|
||||
if (["local", "ssh", "sandbox"].includes(environment.driver)) {
|
||||
return environment.driver === "sandbox" && environment.config.provider === "fake" ? null : { pluginId: null };
|
||||
}
|
||||
if (environment.driver !== "plugin") return null;
|
||||
const plugin = providers.find(value => value.pluginKey === environment.config.pluginKey);
|
||||
const driver = plugin?.manifestJson.environmentDrivers?.find(value => value.driverKey === environment.config.driverKey
|
||||
&& value.kind !== "sandbox_provider" && value.supportsTasks === true);
|
||||
return plugin && driver && plugin.manifestJson.capabilities.includes("environment.drivers.register")
|
||||
? { pluginId: plugin.id } : null;
|
||||
}
|
||||
|
||||
export function assertProjectStorageEnvironment(
|
||||
provider: Pick<Provider, "id" | "pluginKey" | "manifestJson">,
|
||||
environment: Pick<Environment, "driver" | "config" | "name">,
|
||||
) {
|
||||
if (projectStorageForEnvironment(environment, [provider])?.pluginId !== provider.id) {
|
||||
throw conflict(`This project's workspace storage (${provider.manifestJson.displayName}) does not support the environment "${environment.name}". Use an agent in a compatible environment, or create a project for this environment.`,
|
||||
{ code: "project_environment_incompatible", retryable: false });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider, withDefaultProjectStorage } from "./project-workspace-provider.js";
|
||||
import { withDefaultProjectStorage, type ProjectCreationEnvironment } from "./project-environments.js";
|
||||
import { listProjectWorkspaceProviders, resolveProjectWorkspaceProvider } from "./project-workspace-provider.js";
|
||||
import { retireRemovedPluginWorkspaceDependents } from "./plugin-workspaces.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
import { enqueueProjectSkillSource, detachProjectSkillSource } from "./project-skill-source-bindings.js";
|
||||
@@ -115,7 +116,6 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
privacyOwnerUserId: userId,
|
||||
executionWorkspacePolicy: await withDefaultProjectStorage(dbOrTx, companyId, null) as Record<string, unknown> | null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
@@ -654,6 +654,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
const createProject = async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
environment: ProjectCreationEnvironment = {},
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
@@ -674,7 +675,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
// skipped validation whenever `goalIds: []` and `goalId` arrive
|
||||
// together (goalIds wins resolution, mirroring the update path).
|
||||
const legacyGoalId = ids?.[0] ?? null;
|
||||
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null) as typeof projectData.executionWorkspacePolicy;
|
||||
const executionWorkspacePolicy = await withDefaultProjectStorage(db, companyId, projectData.executionWorkspacePolicy ?? null, environment) as typeof projectData.executionWorkspacePolicy;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
@@ -919,10 +920,10 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
};
|
||||
},
|
||||
|
||||
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals> => {
|
||||
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[], environment: ProjectCreationEnvironment = {}): Promise<ProjectWithGoals> => {
|
||||
return db.transaction(async (tx) => {
|
||||
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||
const project = await service.create(companyId, data);
|
||||
const project = await service.create(companyId, data, environment);
|
||||
for (const repo of repositories) {
|
||||
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"packages/shared/src/home-paths.ts": "fa4f1c440653b76b3dbb7a51026d557287deb45d",
|
||||
"packages/shared/src/humanize-connection.test.ts": "ba9e90a44c63452b0bf9f733adb90241921e8eca",
|
||||
"packages/shared/src/humanize-connection.ts": "fbd414476a0ef7106a7c80d8572208781b091699",
|
||||
"packages/shared/src/index.ts": "7ce86fb2ce04fa07ee4e742af137af2dfd10a3f3",
|
||||
"packages/shared/src/index.ts": "a4ec3e9cca1eb3954d247eabd184b103ccefc2a2",
|
||||
"packages/shared/src/issue-activity-identity.test.ts": "3dc294640f54c2be67943e3e0149cf4eff2c95ca",
|
||||
"packages/shared/src/issue-activity-identity.ts": "1b0f07ec3fe0506aaaf80a4fa9893f3baf8f0075",
|
||||
"packages/shared/src/issue-attribution.test.ts": "84563685e7f3c48029243cf1cc24a3a09ee3cde9",
|
||||
@@ -397,14 +397,14 @@
|
||||
"packages/shared/src/types/inbox-agent-policy.ts": "cb9d41d9681e80b834889268ad96015a39df92f6",
|
||||
"packages/shared/src/types/inbox-dismissal.ts": "5d2c71ce44ad77266d286ec1b1119ab1f0390a9d",
|
||||
"packages/shared/src/types/inbox-mine.ts": "1d302b531d34842189704abd2f75f8743d09490d",
|
||||
"packages/shared/src/types/index.ts": "0f9b69e2285e600dbcbcc4e37acafa9bfed65b5c",
|
||||
"packages/shared/src/types/index.ts": "01845e2a041eb892c3a193685471d635da2c1be9",
|
||||
"packages/shared/src/types/instance.ts": "2c40a18c05010991d93d719937faa4a43e47104f",
|
||||
"packages/shared/src/types/issue-tree-control.ts": "75489fffd491b8350b05caf57d7796419797d9df",
|
||||
"packages/shared/src/types/issue.ts": "4598caecc50e60450a1204de832a4fcd7991096d",
|
||||
"packages/shared/src/types/live.ts": "b90e55e4de8600c6fc17d7fa8d89079d4ae655ec",
|
||||
"packages/shared/src/types/native-finalization.ts": "c026a73003ae232fea4f7624d561493b383df358",
|
||||
"packages/shared/src/types/plugin.ts": "07c409d7daca3fc9ad550538d4a7ecac03176a07",
|
||||
"packages/shared/src/types/project.ts": "43a5232bdd1843301477403568ce0c9bc5e26de5",
|
||||
"packages/shared/src/types/project.ts": "ae2e718acd652e362cf1ef91e69aad96d12042fe",
|
||||
"packages/shared/src/types/quota.ts": "a0686a9afb3111a4de093f66200dc0c9152e883e",
|
||||
"packages/shared/src/types/resource-memberships.ts": "9eab6fad50facf3e489f8c40e5bedd92e1d1faf8",
|
||||
"packages/shared/src/types/routine.ts": "e5e5a93d63233029b9bf258558ad155677a4eca7",
|
||||
@@ -485,7 +485,7 @@
|
||||
"packages/shared/src/validators/partial.ts": "fa60ec7481fc14175365e9e500c814b159f1ab53",
|
||||
"packages/shared/src/validators/plugin.test.ts": "e145e15cb98ffe35880b9f8eea88809910ed5886",
|
||||
"packages/shared/src/validators/plugin.ts": "318fac91061e0373f9acd7e9b29cb427dfdc4b7f",
|
||||
"packages/shared/src/validators/project.ts": "61839f7a1b530f4d4e15df7546acf8b5f4a9843f",
|
||||
"packages/shared/src/validators/project.ts": "0257acc29400f438aefc0a657250d8f28ec60838",
|
||||
"packages/shared/src/validators/provider-trace.ts": "b38b6200a2e51513f5dca0f8b008f2fe68175e82",
|
||||
"packages/shared/src/validators/resource-memberships.ts": "b124ac8a8f24b4dbb03ae82bcc87541f6b4ecdfd",
|
||||
"packages/shared/src/validators/routine.test.ts": "1501907ade577c1ab4c4b3098397e5d080446878",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Project,
|
||||
ProjectAccessMember,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectEnvironmentOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectWorkspace,
|
||||
WorkspaceOperation,
|
||||
@@ -21,6 +22,7 @@ function projectPath(id: string, companyId?: string, suffix = "") {
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
environmentOptions: (companyId: string) => api.get<ProjectEnvironmentOptions>(`/companies/${companyId}/project-environments`),
|
||||
gitAuthorization: (id: string) => api.get<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization")),
|
||||
setGitAuthorization: (id: string, grantId: string | null) => api.put<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization"), { grantId }),
|
||||
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
|
||||
|
||||
@@ -6,10 +6,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { getLastProjectId } from "../lib/recent-projects";
|
||||
import { NewProjectForm } from "./NewProjectDialog";
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn() } }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), environmentOptions: vi.fn() } }));
|
||||
vi.mock("./ProjectRepositoryInput", () => ({
|
||||
ProjectRepositoryInput: () => null,
|
||||
repositoryOptionsKey: (companyId: string) => ["repositories", companyId],
|
||||
@@ -25,7 +26,8 @@ let root: ReturnType<typeof createRoot>;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(projectsApi.create).mockReset();
|
||||
vi.mocked(projectsApi.create).mockReset().mockResolvedValue({ id: "project-created" } as Awaited<ReturnType<typeof projectsApi.create>>);
|
||||
vi.mocked(projectsApi.environmentOptions).mockReset().mockResolvedValue({ groups: [], defaultEnvironmentId: null, error: null });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -49,6 +51,7 @@ describe("new project colors", () => {
|
||||
act(() => root.render(<QueryClientProvider client={new QueryClient({ defaultOptions: { mutations: { retry: false } } })}>
|
||||
<NewProjectForm companyId="company-1" onClose={onClose} />
|
||||
</QueryClientProvider>));
|
||||
await settle();
|
||||
const previewColor = container.querySelector<HTMLElement>('span[style]')!.style.backgroundColor;
|
||||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
|
||||
act(() => {
|
||||
@@ -70,3 +73,108 @@ describe("new project colors", () => {
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
const groups = [
|
||||
{ storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "ssh", name: "Build host" }] },
|
||||
{ storageProviderPluginId: "computer-plugin", environments: [{ id: "computer", name: "Agent computers" }] },
|
||||
];
|
||||
|
||||
async function renderForm() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
act(() => root.render(<QueryClientProvider client={client}>
|
||||
<NewProjectForm companyId="company-1" onClose={vi.fn()} />
|
||||
</QueryClientProvider>));
|
||||
await settle();
|
||||
const input = container.querySelector<HTMLInputElement>('input[aria-label="Project name"]')!;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Environment project");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("project environment selection", () => {
|
||||
it("groups compatible environments and follows the user's default within a group", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "ssh", error: null });
|
||||
vi.mocked(projectsApi.create).mockResolvedValue({ id: "project" } as Awaited<ReturnType<typeof projectsApi.create>>);
|
||||
await renderForm();
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select.options).toHaveLength(2);
|
||||
expect(select.options[0].textContent).toBe("Paperclip Sandbox, Build host");
|
||||
expect(select.value).toBe("sandbox");
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" }));
|
||||
});
|
||||
|
||||
it("sends an explicit choice and preserves it after a create failure", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: "computer", error: null });
|
||||
vi.mocked(projectsApi.create).mockRejectedValue(new Error("Try again"));
|
||||
await renderForm();
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
expect(select.value).toBe("computer");
|
||||
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "sandbox", expectedStorageProviderPluginId: null }));
|
||||
expect(select.value).toBe("sandbox");
|
||||
expect(vi.mocked(projectsApi.create).mock.calls[0][1].executionWorkspacePolicy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the picker for compatible environments but still sends the default", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups: [groups[0]], defaultEnvironmentId: "ssh", error: null });
|
||||
await renderForm();
|
||||
expect(container.querySelector("select")).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
await settle();
|
||||
expect(projectsApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ environmentId: "ssh" }));
|
||||
});
|
||||
|
||||
it("blocks submission while discovery is pending", async () => {
|
||||
let resolve!: (value: Awaited<ReturnType<typeof projectsApi.environmentOptions>>) => void;
|
||||
vi.mocked(projectsApi.environmentOptions).mockReturnValue(new Promise(value => { resolve = value; }));
|
||||
await renderForm();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
act(() => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||||
expect(projectsApi.create).not.toHaveBeenCalled();
|
||||
await act(async () => resolve({ groups, defaultEnvironmentId: "computer", error: null }));
|
||||
await settle();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
it("reports discovery failures and lets the user retry", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockRejectedValueOnce(new Error("Environment lookup failed"))
|
||||
.mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null });
|
||||
await renderForm();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Environment lookup failed");
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Retry")!;
|
||||
act(() => retry.click());
|
||||
await settle();
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps cached environment choices during a transient refetch failure", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValueOnce({ groups, defaultEnvironmentId: "sandbox", error: null })
|
||||
.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const client = await renderForm();
|
||||
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.projects.environments("company-1") }); });
|
||||
await settle();
|
||||
expect(container.querySelector<HTMLSelectElement>("select")?.value).toBe("sandbox");
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not silently substitute storage when the default is unavailable", async () => {
|
||||
vi.mocked(projectsApi.environmentOptions).mockResolvedValue({ groups, defaultEnvironmentId: null, error: "Default is unavailable" });
|
||||
await renderForm();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Default is unavailable");
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
const select = container.querySelector<HTMLSelectElement>("select")!;
|
||||
act(() => { select.value = "sandbox"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
expect(container.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, type ProjectRepository } from "@paperclipai/shared";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -10,6 +10,8 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { trackRecentProject } from "../lib/recent-projects";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
||||
import { QueryErrorState, useQueryView } from "./QueryView";
|
||||
import { NativeSelect } from "./ui/select";
|
||||
import { Button } from "./ui/button";
|
||||
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
|
||||
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
|
||||
@@ -28,8 +30,16 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
const [repos, setRepos] = useState<ProjectRepository[]>([]);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(null);
|
||||
const environments = useQuery({ queryKey: queryKeys.projects.environments(companyId), queryFn: () => projectsApi.environmentOptions(companyId), gcTime: 0 });
|
||||
const environmentView = useQueryView(environments);
|
||||
const options = environmentView.data;
|
||||
const environmentId = selectedEnvironmentId ?? options?.defaultEnvironmentId;
|
||||
const selectedGroup = options?.groups.find(group => group.environments.some(environment => environment.id === environmentId));
|
||||
const environmentReady = Boolean(environmentView.kind !== "error" && options && (!options.error || selectedGroup) && (!options.groups.length || selectedGroup));
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
|
||||
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), color, status: "planned", repositoryIds: repos.map((repo) => repo.id), ...(selectedGroup ? { environmentId, expectedStorageProviderPluginId: selectedGroup.storageProviderPluginId } : {}) }),
|
||||
onSuccess: (project) => {
|
||||
trackRecentProject(project.id, companyId);
|
||||
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
|
||||
@@ -46,7 +56,7 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) });
|
||||
setConnecting(false);
|
||||
}} />
|
||||
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}>
|
||||
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && environmentReady && !create.isPending) create.mutate(); }}>
|
||||
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
|
||||
@@ -59,12 +69,27 @@ export function NewProjectForm({ companyId, onClose }: { companyId: string; onCl
|
||||
</div>
|
||||
</div>
|
||||
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
|
||||
{options && (options.groups.length > 1 || (options.error && options.groups.length > 0)) && <div className="mb-4 space-y-2">
|
||||
<label htmlFor="project-environment" className="text-sm font-medium">Available in</label>
|
||||
<NativeSelect id="project-environment" value={selectedGroup?.environments[0].id ?? ""} disabled={create.isPending}
|
||||
onChange={event => setSelectedEnvironmentId(event.target.value)}>
|
||||
{!selectedGroup && <option value="" disabled>Select environments</option>}
|
||||
{options.groups.map(group => <option key={group.environments[0].id} value={group.environments[0].id}>
|
||||
{group.environments.map(environment => environment.name).join(", ")}
|
||||
</option>)}
|
||||
</NativeSelect>
|
||||
<p className="text-xs text-muted-foreground">These environments can share this project's working files. The storage choice stays with the project.</p>
|
||||
</div>}
|
||||
{(environmentView.kind === "loading" || environmentView.kind === "reconnecting") && <p role="status" className="mb-3 text-sm text-muted-foreground">Loading environments…</p>}
|
||||
{environmentView.kind === "error" && <QueryErrorState error={environmentView.error} size="inline" action="load project environments"
|
||||
className="mb-3" onRetry={environmentView.retry} retrying={environmentView.isFetching} />}
|
||||
{options?.error && !selectedGroup && <p role="alert" className="mb-3 text-sm text-destructive">{options.error}</p>}
|
||||
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
|
||||
</div>
|
||||
{create.status === "error" && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{describeError(create.error).body}</p>}
|
||||
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
|
||||
<div className="flex shrink-0 justify-between gap-2 px-5 py-5">
|
||||
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !environmentReady || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</DialogContent>
|
||||
|
||||
@@ -517,6 +517,7 @@ export const queryKeys = {
|
||||
["environment-custom-image-setup-sessions", sessionId] as const,
|
||||
},
|
||||
projects: {
|
||||
environments: (companyId: string) => ["project-environments", companyId] as const,
|
||||
all: (companyId: string) => ["projects", companyId] as const,
|
||||
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
|
||||
[
|
||||
|
||||
@@ -335,6 +335,13 @@ function hydrateDialogQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
|
||||
queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents);
|
||||
queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects);
|
||||
queryClient.setQueryData(queryKeys.projects.environments(COMPANY_ID), {
|
||||
groups: [
|
||||
{ storageProviderPluginId: null, environments: [{ id: "sandbox", name: "Paperclip Sandbox" }, { id: "local", name: "Local" }] },
|
||||
{ storageProviderPluginId: "computer-storage", environments: [{ id: "computers", name: "Agent computers" }] },
|
||||
],
|
||||
defaultEnvironmentId: "sandbox", error: null,
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.goals.list(COMPANY_ID), storybookGoals);
|
||||
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
|
||||
queryClient.setQueryData(queryKeys.issues.labels(COMPANY_ID), storybookIssueLabels);
|
||||
|
||||
Reference in new issue
Block a user