diff --git a/packages/plugins/sandbox-providers/cloudflare/README.md b/packages/plugins/sandbox-providers/cloudflare/README.md index d7e01d1b251..1799bdecdd6 100644 --- a/packages/plugins/sandbox-providers/cloudflare/README.md +++ b/packages/plugins/sandbox-providers/cloudflare/README.md @@ -12,7 +12,7 @@ From a Paperclip instance, install: @paperclipai/plugin-cloudflare-sandbox ``` -Configure Cloudflare from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Cloudflare from `Instance Settings -> Environments`, not from the plugin's plugin page. ## Configuration diff --git a/packages/plugins/sandbox-providers/daytona/README.md b/packages/plugins/sandbox-providers/daytona/README.md index 4d557ee2082..facdc7526c9 100644 --- a/packages/plugins/sandbox-providers/daytona/README.md +++ b/packages/plugins/sandbox-providers/daytona/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure Daytona from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Daytona from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the Daytona API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/plugins/sandbox-providers/e2b/README.md b/packages/plugins/sandbox-providers/e2b/README.md index 63e32391b00..b03a31128c6 100644 --- a/packages/plugins/sandbox-providers/e2b/README.md +++ b/packages/plugins/sandbox-providers/e2b/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure E2B from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure E2B from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the E2B API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/plugins/sandbox-providers/exe-dev/README.md b/packages/plugins/sandbox-providers/exe-dev/README.md index b90eca6923b..9e4a6b119b3 100644 --- a/packages/plugins/sandbox-providers/exe-dev/README.md +++ b/packages/plugins/sandbox-providers/exe-dev/README.md @@ -14,7 +14,7 @@ From a Paperclip instance, install: ## Configuration -Configure exe.dev from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure exe.dev from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the exe.dev API token on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys and pasted SSH private keys as company secrets. diff --git a/packages/plugins/sandbox-providers/modal/README.md b/packages/plugins/sandbox-providers/modal/README.md index b3966004ec3..054b5d71372 100644 --- a/packages/plugins/sandbox-providers/modal/README.md +++ b/packages/plugins/sandbox-providers/modal/README.md @@ -22,7 +22,7 @@ The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues ## Configuration -Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Modal from `Instance Settings -> Environments`, not from the plugin's plugin page. | Field | Required | Description | | --- | --- | --- | @@ -63,7 +63,7 @@ These commands assume the repo root has already been installed once so the local 1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account. 2. Install the plugin from the Paperclip Plugins page. -3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`. +3. In `Instance Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`. 4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip. 5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up. diff --git a/packages/plugins/sandbox-providers/novita/README.md b/packages/plugins/sandbox-providers/novita/README.md index 2026978d369..b2d5f4e9d10 100644 --- a/packages/plugins/sandbox-providers/novita/README.md +++ b/packages/plugins/sandbox-providers/novita/README.md @@ -16,7 +16,7 @@ The host plugin installer runs `npm install` into the managed plugin directory, ## Configuration -Configure Novita from `Company Settings -> Environments`, not from the plugin's instance settings page. +Configure Novita from `Instance Settings -> Environments`, not from the plugin's plugin page. - Put the Novita API key on the sandbox environment itself. - When you save an environment, Paperclip stores pasted API keys as company secrets. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2a5ca16754b..319f484f9f8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -903,12 +903,14 @@ export { type PatchInstanceGeneralSettings, instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, trustPresetSchema, lowTrustBoundarySchema, lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema, type PatchInstanceExperimentalSettings, + type PatchInstanceSettings, type IssueGraphLivenessAutoRecoveryRequest, type TrustPresetInput, type LowTrustBoundaryInput, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 893a1cda4f3..17c4e8edca0 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enableConferenceRoomChat: boolean; + enableTaskWatchdogs: boolean; enableIssuePlanDecompositions: boolean; enableExperimentalFileViewer: boolean; enableTaskWatchdogs: boolean; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 6035ba91ca4..13ba4154bca 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -162,6 +162,7 @@ export interface ProjectExecutionWorkspacePolicy { export interface IssueExecutionWorkspaceSettings { mode?: ExecutionWorkspaceMode; + environmentId?: string | null; workspaceStrategy?: ExecutionWorkspaceStrategy | null; workspaceRuntime?: Record | null; } diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 43b178c2cab..6323a7dc880 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -6,9 +6,11 @@ export { type PatchInstanceGeneralSettings, instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, type InstanceExperimentalSettings, type PatchInstanceExperimentalSettings, + type PatchInstanceSettings, type IssueGraphLivenessAutoRecoveryRequest, } from "./instance.js"; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 0d9be2f1c1e..a854aae4d2f 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -43,6 +43,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), + enableTaskWatchdogs: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableTaskWatchdogs: z.boolean().default(false), @@ -59,6 +60,10 @@ export const instanceExperimentalSettingsSchema = z.object({ export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); +export const patchInstanceSettingsSchema = z.object({ + defaultEnvironmentId: z.string().uuid().nullable().optional(), +}).strict(); + export const issueGraphLivenessAutoRecoveryRequestSchema = z.object({ lookbackHours: z .number() @@ -72,6 +77,7 @@ export type InstanceGeneralSettings = z.infer; export type InstanceExperimentalSettings = z.infer; export type PatchInstanceExperimentalSettings = z.infer; +export type PatchInstanceSettings = z.infer; export type IssueGraphLivenessAutoRecoveryRequest = z.infer< typeof issueGraphLivenessAutoRecoveryRequestSchema >; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index f022f703cd3..f1e8fcb95e0 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -117,6 +117,7 @@ const executionWorkspaceStrategySchema = z export const issueExecutionWorkspaceSettingsSchema = z .object({ mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), + environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(), }) diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 98b12bba20a..969785b40a5 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -3,8 +3,10 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), getGeneral: vi.fn(), getExperimental: vi.fn(), + update: vi.fn(), updateGeneral: vi.fn(), updateExperimental: vi.fn(), listCompanyIds: vi.fn(), @@ -48,14 +50,37 @@ describe("instance settings routes", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.clearAllMocks(); + mockInstanceSettingsService.get.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); mockInstanceSettingsService.getExperimental.mockReset(); + mockInstanceSettingsService.update.mockReset(); mockInstanceSettingsService.updateGeneral.mockReset(); mockInstanceSettingsService.updateExperimental.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset(); mockHeartbeatService.reconcileIssueGraphLiveness.mockReset(); mockLogActivity.mockReset(); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + defaultEnvironmentId: null, + general: { + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + }, + experimental: { + enableEnvironments: false, + enableIsolatedWorkspaces: false, + enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, + enableCloudSync: false, + autoRestartDevServerWhenIdle: false, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 24, + }, + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + }); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, keyboardShortcuts: false, @@ -72,6 +97,27 @@ describe("instance settings routes", () => { enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, }); + mockInstanceSettingsService.update.mockResolvedValue({ + id: "instance-settings-1", + defaultEnvironmentId: "env-1", + general: { + censorUsernameInLogs: false, + keyboardShortcuts: false, + feedbackDataSharingPreference: "prompt", + }, + experimental: { + enableEnvironments: true, + enableIsolatedWorkspaces: true, + enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, + enableCloudSync: true, + autoRestartDevServerWhenIdle: false, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 24, + }, + createdAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T01:00:00.000Z", + }); mockInstanceSettingsService.updateGeneral.mockResolvedValue({ id: "instance-settings-1", general: { @@ -151,6 +197,29 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }, 10_000); + it("allows local board users to read and update the instance default environment", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + const getRes = await request(app).get("/api/instance/settings"); + expect(getRes.status).toBe(200); + expect(getRes.body.defaultEnvironmentId).toBeNull(); + + const patchRes = await request(app) + .patch("/api/instance/settings") + .send({ defaultEnvironmentId: "11111111-1111-4111-8111-111111111111" }); + + expect(patchRes.status).toBe(200); + expect(mockInstanceSettingsService.update).toHaveBeenCalledWith({ + defaultEnvironmentId: "11111111-1111-4111-8111-111111111111", + }); + expect(mockLogActivity).toHaveBeenCalledTimes(2); + }); + it("allows local board users to update guarded dev-server auto-restart", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 0d9acaf99ef..45e212e5b55 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -2,6 +2,7 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { issueGraphLivenessAutoRecoveryRequestSchema, + patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema, } from "@paperclipai/shared"; @@ -25,6 +26,41 @@ export function instanceSettingsRoutes(db: Db) { const svc = instanceSettingsService(db); const heartbeat = heartbeatService(db); + router.get("/instance/settings", async (req, res) => { + assertBoardOrgAccess(req); + res.json(await svc.get()); + }); + + router.patch( + "/instance/settings", + validate(patchInstanceSettingsSchema), + async (req, res) => { + assertCanManageInstanceSettings(req); + const updated = await svc.update(req.body); + const actor = getActorInfo(req); + const companyIds = await svc.listCompanyIds(); + await Promise.all( + companyIds.map((companyId) => + logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "instance.settings.updated", + entityType: "instance_settings", + entityId: updated.id, + details: { + defaultEnvironmentId: updated.defaultEnvironmentId, + changedKeys: Object.keys(req.body).sort(), + }, + }), + ), + ); + res.json(updated); + }, + ); + router.get("/instance/settings/general", async (req, res) => { // General settings (e.g. keyboardShortcuts) are readable by any // authenticated org member or instance admin. Only PATCH requires instance-admin. diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4225cb17352..ccf38a30e02 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -110,6 +110,7 @@ import { // Instance settings patchInstanceGeneralSettingsSchema, patchInstanceExperimentalSettingsSchema, + patchInstanceSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, // Resource memberships updateResourceMembershipSchema, @@ -2458,6 +2459,23 @@ registry.registerPath({ // ─── Instance settings ──────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/instance/settings", + tags: ["instance"], + summary: "Get instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings", + tags: ["instance"], + summary: "Update instance settings", + request: { body: jsonBody(patchInstanceSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + registry.registerPath({ method: "get", path: "/api/instance/settings/general", diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 81e7f0ac196..4bb7c3e32f0 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -10,6 +10,7 @@ import { type InstanceExperimentalSettings, type PatchInstanceGeneralSettings, type InstanceSettings, + type PatchInstanceSettings, type PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; import { eq } from "drizzle-orm"; @@ -129,6 +130,22 @@ export function instanceSettingsService(db: Db) { return { get: async (): Promise => toInstanceSettings(await getOrCreateRow()), + update: async (patch: PatchInstanceSettings): Promise => { + const current = await getOrCreateRow(); + const now = new Date(); + const [updated] = await db + .update(instanceSettings) + .set({ + ...(Object.prototype.hasOwnProperty.call(patch, "defaultEnvironmentId") + ? { defaultEnvironmentId: patch.defaultEnvironmentId ?? null } + : {}), + updatedAt: now, + }) + .where(eq(instanceSettings.id, current.id)) + .returning(); + return toInstanceSettings(updated ?? current); + }, + getGeneral: async (): Promise => { const row = await getOrCreateRow(); return normalizeGeneralSettings(row.general); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 01a1e81b082..c8de22cf6a1 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -871,7 +871,7 @@ export function secretService(db: Db) { type: "environment", id: row.id, label: row.name, - href: "/company/settings/environments", + href: "/company/settings/instance/environments", status: row.status, }); } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba6e9e08eb2..07dfeddcee8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -79,7 +79,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -91,6 +91,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/instanceSettings.ts b/ui/src/api/instanceSettings.ts index 4a8544b88c1..ca8b7841b5a 100644 --- a/ui/src/api/instanceSettings.ts +++ b/ui/src/api/instanceSettings.ts @@ -1,13 +1,19 @@ import type { InstanceExperimentalSettings, InstanceGeneralSettings, + InstanceSettings, IssueGraphLivenessAutoRecoveryPreview, + PatchInstanceSettings, PatchInstanceGeneralSettings, PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; import { api } from "./client"; export const instanceSettingsApi = { + get: () => + api.get("/instance/settings"), + update: (patch: PatchInstanceSettings) => + api.patch("/instance/settings", patch), getGeneral: () => api.get("/instance/settings/general"), updateGeneral: (patch: PatchInstanceGeneralSettings) => diff --git a/ui/src/components/AgentConfigForm.test.ts b/ui/src/components/AgentConfigForm.test.ts index a17b05ae527..51e2e107591 100644 --- a/ui/src/components/AgentConfigForm.test.ts +++ b/ui/src/components/AgentConfigForm.test.ts @@ -78,12 +78,12 @@ describe("agent config test action", () => { function makeEnvironment(overrides: Partial): Environment { return { id: "env-1", - companyId: "co-1", name: "Env", description: null, driver: "local", status: "active", config: {}, + envVars: {}, metadata: null, createdAt: new Date(0), updatedAt: new Date(0), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 977673e1fe6..5355d2f1b37 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -249,6 +249,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryFn: () => instanceSettingsApi.getGeneral(), retry: false, }); + const { data: instanceSettings } = useQuery({ + queryKey: queryKeys.instance.settings, + queryFn: () => instanceSettingsApi.get(), + retry: false, + }); const { data: environments = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], @@ -368,13 +373,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const set = isCreate ? (patch: Partial) => props.onChange(patch) : null; - const currentDefaultEnvironmentId = isCreate + const rawCurrentDefaultEnvironmentId = isCreate ? val!.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const currentDefaultEnvironmentId = useMemo(() => { + if (!rawCurrentDefaultEnvironmentId) return ""; + const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null; + return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId; + }, [environments, rawCurrentDefaultEnvironmentId]); const currentDefaultEnvironment = useMemo( () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, [currentDefaultEnvironmentId, environments], ); + const instanceDefaultEnvironmentId = useMemo(() => { + const environmentId = instanceSettings?.defaultEnvironmentId ?? null; + if (!environmentId) return ""; + const selected = environments.find((environment) => environment.id === environmentId) ?? null; + return selected?.driver === "local" ? "" : environmentId; + }, [environments, instanceSettings?.defaultEnvironmentId]); + const instanceDefaultEnvironment = useMemo( + () => environments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null, + [environments, instanceDefaultEnvironmentId], + ); // When the instance forces Kubernetes execution, new agents must default to the // managed Kubernetes sandbox environment (never the implicit local default). @@ -389,12 +409,21 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const runnableEnvironments = useMemo( () => environments.filter((environment) => { if (!supportedEnvironmentDrivers.has(environment.driver)) return false; + if (environment.driver === "local") return false; if (environment.driver !== "sandbox") return true; const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }), [environments, supportedEnvironmentDrivers], ); + const showEnvironmentOverrideControl = environmentsEnabled && ( + forcedKubernetes || + currentDefaultEnvironmentId.length > 0 || + runnableEnvironments.length > 1 + ); + const inheritedEnvironmentLabel = instanceDefaultEnvironment + ? `${instanceDefaultEnvironment.name} (${instanceDefaultEnvironment.driver})` + : "Local"; // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId @@ -897,7 +926,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { - ) : environmentsEnabled ? ( + ) : showEnvironmentOverrideControl ? (
{cards ?

Execution

@@ -905,28 +934,35 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }
- +
+
+ {currentDefaultEnvironment + ? `Overriding the instance default with ${currentDefaultEnvironment.name}.` + : `Inheriting the instance default: ${inheritedEnvironmentLabel}.`} +
+ +
diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d69dd9aeb76..ea05266794e 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -169,7 +169,7 @@ describe("CompanySettingsSidebar", () => { ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ - to: "/company/settings/environments", + to: "/company/settings/instance/environments", label: "Environments", end: true, }), diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index aab43ec8845..a7243a74d1f 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -105,12 +105,6 @@ export function CompanySettingsSidebar() {
- {showCloudUpstream ? ( + + diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index 3133db0c99b..aeb99865df9 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -135,7 +135,7 @@ describe("IssueWorkspaceCard", () => { container.remove(); }); - it("locks the environment selector and clears the issue override when reusing a workspace", () => { + it("clears the legacy issue environment override when reusing a workspace", () => { const root = createRoot(container); const onUpdate = vi.fn(); const reusableWorkspace = createExecutionWorkspace(); @@ -180,12 +180,7 @@ describe("IssueWorkspaceCard", () => { }); const selects = container.querySelectorAll("select"); - expect(selects).toHaveLength(3); - - const environmentSelect = selects[2] as HTMLSelectElement; - expect(environmentSelect.disabled).toBe(true); - expect(environmentSelect.value).toBe("env-workspace"); - expect(container.textContent).toContain("Environment selection is locked while reusing an existing workspace."); + expect(selects).toHaveLength(2); const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Save")); expect(saveButton).not.toBeUndefined(); diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index f8e1a03532c..f5a001b5a00 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -262,7 +262,6 @@ export function IssueWorkspaceCard({ const [draftSelection, setDraftSelection] = useState(currentSelection); const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); - const [draftEnvironmentId, setDraftEnvironmentId] = useState(issue.executionWorkspaceSettings?.environmentId ?? ""); const projectEnvironmentId = environmentsEnabled ? project?.executionWorkspacePolicy?.environmentId ?? null : null; @@ -271,7 +270,6 @@ export function IssueWorkspaceCard({ ? ( (currentSelection === "reuse_existing" && currentReusableEnvironmentId) ?? workspace?.config?.environmentId - ?? issue.executionWorkspaceSettings?.environmentId ?? projectEnvironmentId ) : null; @@ -283,8 +281,7 @@ export function IssueWorkspaceCard({ if (editing) return; setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); - setDraftEnvironmentId(issue.executionWorkspaceSettings?.environmentId ?? ""); - }, [currentSelection, editing, issue.executionWorkspaceId, issue.executionWorkspaceSettings?.environmentId]); + }, [currentSelection, editing, issue.executionWorkspaceId]); const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace"); @@ -304,17 +301,6 @@ export function IssueWorkspaceCard({ }); const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; - const reuseExistingSelection = draftSelection === "reuse_existing"; - const selectedReusableEnvironmentId = configuredReusableWorkspace?.config?.environmentId ?? ""; - const runSelectableEnvironments = useMemo( - () => environmentsEnabled ? (environments ?? []).filter((environment) => { - if (environment.driver === "local" || environment.driver === "ssh") return true; - if (environment.driver !== "sandbox") return false; - const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; - return provider !== null && provider !== "fake"; - }) : [], - [environments, environmentsEnabled], - ); const draftWorkspaceBranchName = draftSelection === "reuse_existing" && configuredReusableWorkspace?.mode !== "shared_workspace" ? configuredReusableWorkspace?.branchName ?? null @@ -328,11 +314,10 @@ export function IssueWorkspaceCard({ draftSelection === "reuse_existing" ? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode) : draftSelection, - environmentId: draftSelection === "reuse_existing" ? null : draftEnvironmentId || null, + environmentId: null, }, }), [ configuredReusableWorkspace?.mode, - draftEnvironmentId, draftExecutionWorkspaceId, draftSelection, ]); @@ -358,9 +343,8 @@ export function IssueWorkspaceCard({ const handleCancel = useCallback(() => { setDraftSelection(currentSelection); setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); - setDraftEnvironmentId(issue.executionWorkspaceSettings?.environmentId ?? ""); setEditing(false); - }, [currentSelection, issue.executionWorkspaceId, issue.executionWorkspaceSettings?.environmentId]); + }, [currentSelection, issue.executionWorkspaceId]); if (!policyEnabled || !project) return null; @@ -520,42 +504,6 @@ export function IssueWorkspaceCard({ )} - {environmentsEnabled ? ( - <> - - {reuseExistingSelection && ( -
- {configuredReusableWorkspace - ? "Environment selection is locked while reusing an existing workspace. The next run will use that workspace's persisted environment config." - : "Choose an existing workspace first. Its persisted environment config will determine the next run."} -
- )} - - ) : null} - {/* Current workspace summary when editing */} {workspace && (
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index c4f7b31b5e6..b2d159e7dbb 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -308,6 +308,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null; return provider !== null && provider !== "fake"; }); + const showExecutionWorkspaceEnvironmentControl = environmentsEnabled && runSelectableEnvironments.length > 1; const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); @@ -1000,7 +1001,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Host-managed implementation: Git worktree
- {environmentsEnabled ? ( + {showExecutionWorkspaceEnvironmentControl ? (