mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Move environments into instance settings
This commit is contained in:
1 parent
20853c196a
commit
cb40d9afea
36 files changed
+397
-132
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings {
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface ProjectExecutionWorkspacePolicy {
|
||||
|
||||
export interface IssueExecutionWorkspaceSettings {
|
||||
mode?: ExecutionWorkspaceMode;
|
||||
environmentId?: string | null;
|
||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||
workspaceRuntime?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ export {
|
||||
type PatchInstanceGeneralSettings,
|
||||
instanceExperimentalSettingsSchema,
|
||||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
type PatchInstanceSettings,
|
||||
type IssueGraphLivenessAutoRecoveryRequest,
|
||||
} from "./instance.js";
|
||||
|
||||
|
||||
@@ -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<typeof instanceGeneralSettingsSche
|
||||
export type PatchInstanceGeneralSettings = z.infer<typeof patchInstanceGeneralSettingsSchema>;
|
||||
export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
|
||||
export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>;
|
||||
export type PatchInstanceSettings = z.infer<typeof patchInstanceSettingsSchema>;
|
||||
export type IssueGraphLivenessAutoRecoveryRequest = z.infer<
|
||||
typeof issueGraphLivenessAutoRecoveryRequestSchema
|
||||
>;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
update: async (patch: PatchInstanceSettings): Promise<InstanceSettings> => {
|
||||
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<InstanceGeneralSettings> => {
|
||||
const row = await getOrCreateRow();
|
||||
return normalizeGeneralSettings(row.general);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-1
@@ -79,7 +79,7 @@ function boardRoutes() {
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
@@ -91,6 +91,7 @@ function boardRoutes() {
|
||||
<Route path="company/settings/instance" element={<Navigate to="general" replace />} />
|
||||
<Route path="company/settings/instance/profile" element={<ProfileSettings />} />
|
||||
<Route path="company/settings/instance/general" element={<InstanceGeneralSettings />} />
|
||||
<Route path="company/settings/instance/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/instance/access" element={<InstanceAccess />} />
|
||||
<Route path="company/settings/instance/heartbeats" element={<InstanceSettings />} />
|
||||
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
|
||||
|
||||
@@ -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<InstanceSettings>("/instance/settings"),
|
||||
update: (patch: PatchInstanceSettings) =>
|
||||
api.patch<InstanceSettings>("/instance/settings", patch),
|
||||
getGeneral: () =>
|
||||
api.get<InstanceGeneralSettings>("/instance/settings/general"),
|
||||
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
|
||||
|
||||
@@ -78,12 +78,12 @@ describe("agent config test action", () => {
|
||||
function makeEnvironment(overrides: Partial<Environment>): 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),
|
||||
|
||||
@@ -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<Environment[]>({
|
||||
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
|
||||
@@ -368,13 +373,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const set = isCreate
|
||||
? (patch: Partial<CreateConfigValues>) => 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) {
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
) : environmentsEnabled ? (
|
||||
) : showEnvironmentOverrideControl ? (
|
||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Execution</h3>
|
||||
@@ -905,28 +934,35 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field
|
||||
label="Default environment"
|
||||
hint="Agent-level default execution target. Project and task settings can still override this."
|
||||
label="Environment override"
|
||||
hint="Leave this unset to inherit the instance default. Agent-specific overrides only appear when there is a real alternative."
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={currentDefaultEnvironmentId}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (isCreate) {
|
||||
set!({ defaultEnvironmentId: nextValue });
|
||||
return;
|
||||
}
|
||||
mark("identity", "defaultEnvironmentId", nextValue || null);
|
||||
}}
|
||||
>
|
||||
<option value="">Company default (Local)</option>
|
||||
{runnableEnvironments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name} · {environment.driver}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{currentDefaultEnvironment
|
||||
? `Overriding the instance default with ${currentDefaultEnvironment.name}.`
|
||||
: `Inheriting the instance default: ${inheritedEnvironmentLabel}.`}
|
||||
</div>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={currentDefaultEnvironmentId}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (isCreate) {
|
||||
set!({ defaultEnvironmentId: nextValue });
|
||||
return;
|
||||
}
|
||||
mark("identity", "defaultEnvironmentId", nextValue || null);
|
||||
}}
|
||||
>
|
||||
<option value="">Inherit instance default ({inheritedEnvironmentLabel})</option>
|
||||
{runnableEnvironments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name} · {environment.driver}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/environments",
|
||||
to: "/company/settings/instance/environments",
|
||||
label: "Environments",
|
||||
end: true,
|
||||
}),
|
||||
|
||||
@@ -105,12 +105,6 @@ export function CompanySettingsSidebar() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem
|
||||
to="/company/settings/environments"
|
||||
label="Environments"
|
||||
icon={MonitorCog}
|
||||
end
|
||||
/>
|
||||
{showCloudUpstream ? (
|
||||
<SidebarNavItem
|
||||
to="/company/settings/cloud-upstream"
|
||||
@@ -156,6 +150,12 @@ export function CompanySettingsSidebar() {
|
||||
icon={SlidersHorizontal}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
|
||||
label="Environments"
|
||||
icon={MonitorCog}
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`}
|
||||
label="Access"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import { Clock3, Cpu, FlaskConical, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal, UserRoundPen } from "lucide-react";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
@@ -41,6 +41,7 @@ export function InstanceSidebar() {
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/profile`} label="Profile" icon={UserRoundPen} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/general`} label="General" icon={SlidersHorizontal} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`} label="Environments" icon={MonitorCog} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/access`} label="Access" icon={Shield} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`} label="Heartbeats" icon={Clock3} end />
|
||||
<SidebarNavItem to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`} label="Experimental" icon={FlaskConical} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
</select>
|
||||
)}
|
||||
|
||||
{environmentsEnabled ? (
|
||||
<>
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none",
|
||||
reuseExistingSelection && "cursor-not-allowed opacity-70",
|
||||
)}
|
||||
value={reuseExistingSelection ? selectedReusableEnvironmentId : draftEnvironmentId}
|
||||
onChange={(e) => setDraftEnvironmentId(e.target.value)}
|
||||
disabled={reuseExistingSelection}
|
||||
>
|
||||
<option value="">
|
||||
{reuseExistingSelection
|
||||
? configuredReusableWorkspace
|
||||
? "No environment on reused workspace"
|
||||
: "Select an existing workspace to inspect its environment"
|
||||
: projectEnvironmentId
|
||||
? "Project default environment"
|
||||
: "No environment"}
|
||||
</option>
|
||||
{runSelectableEnvironments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name} · {environment.driver}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{reuseExistingSelection && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{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."}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Current workspace summary when editing */}
|
||||
{workspace && (
|
||||
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
|
||||
|
||||
@@ -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
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
||||
</div>
|
||||
{environmentsEnabled ? (
|
||||
{showExecutionWorkspaceEnvironmentControl ? (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -66,8 +66,7 @@ describe("CompanySettingsNav", () => {
|
||||
it("maps company settings routes to the expected shared tab value", () => {
|
||||
expect(getCompanySettingsTab("/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("instance-environments");
|
||||
expect(getCompanySettingsTab("/company/settings/cloud-upstream")).toBe("cloud-upstream");
|
||||
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
|
||||
@@ -77,6 +76,7 @@ describe("CompanySettingsNav", () => {
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/secrets")).toBe("secrets");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/profile")).toBe("instance-profile");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/instance/general")).toBe("instance-general");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/environments")).toBe("instance-environments");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/access")).toBe("instance-access");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/heartbeats")).toBe("instance-heartbeats");
|
||||
expect(getCompanySettingsTab("/company/settings/instance/experimental")).toBe("instance-experimental");
|
||||
@@ -98,13 +98,13 @@ describe("CompanySettingsNav", () => {
|
||||
value: "members",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "instance-profile", label: "Instance profile" },
|
||||
{ value: "instance-general", label: "Instance general" },
|
||||
{ value: "instance-environments", label: "Instance environments" },
|
||||
{ value: "instance-access", label: "Instance access" },
|
||||
{ value: "instance-heartbeats", label: "Instance heartbeats" },
|
||||
{ value: "instance-experimental", label: "Instance experimental" },
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useLocation, useNavigate } from "@/lib/router";
|
||||
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
|
||||
{ value: "cloud-upstream", label: "Cloud upstream", href: "/company/settings/cloud-upstream" },
|
||||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
{ value: "secrets", label: "Secrets", href: "/company/settings/secrets" },
|
||||
{ value: "instance-profile", label: "Instance profile", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/profile` },
|
||||
{ value: "instance-general", label: "Instance general", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/general` },
|
||||
{ value: "instance-environments", label: "Instance environments", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/environments` },
|
||||
{ value: "instance-access", label: "Instance access", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/access` },
|
||||
{ value: "instance-heartbeats", label: "Instance heartbeats", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats` },
|
||||
{ value: "instance-experimental", label: "Instance experimental", href: `${INSTANCE_SETTINGS_PATH_PREFIX}/experimental` },
|
||||
@@ -30,6 +30,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
return "instance-access";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`)) {
|
||||
return "instance-environments";
|
||||
}
|
||||
|
||||
if (pathname.includes(`${INSTANCE_SETTINGS_PATH_PREFIX}/heartbeats`)) {
|
||||
return "instance-heartbeats";
|
||||
}
|
||||
@@ -51,7 +55,7 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/environments")) {
|
||||
return "environments";
|
||||
return "instance-environments";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/cloud-upstream")) {
|
||||
|
||||
@@ -12,6 +12,9 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
|
||||
"/company/settings/instance/experimental",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/environments")).toBe(
|
||||
"/company/settings/instance/environments",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
|
||||
"/company/settings/instance/plugins/example?tab=config#logs",
|
||||
);
|
||||
@@ -24,6 +27,9 @@ describe("normalizeRememberedInstanceSettingsPath", () => {
|
||||
expect(normalizeRememberedInstanceSettingsPath("/company/settings/instance/plugins/example?tab=config#logs")).toBe(
|
||||
"/company/settings/instance/plugins/example?tab=config#logs",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/company/settings/environments")).toBe(
|
||||
"/company/settings/instance/environments",
|
||||
);
|
||||
expect(normalizeRememberedInstanceSettingsPath("/settings/access?tab=users#admins")).toBe(
|
||||
"/company/settings/instance/access?tab=users#admins",
|
||||
);
|
||||
|
||||
@@ -31,6 +31,8 @@ function normalizePathForMatching(rawPath: string): { pathname: string; search:
|
||||
}
|
||||
|
||||
function instanceSettingsSuffix(pathname: string): string | null {
|
||||
if (pathname === "/company/settings/environments") return "/environments";
|
||||
|
||||
if (pathname === INSTANCE_SETTINGS_PATH_PREFIX) return "/general";
|
||||
if (pathname.startsWith(`${INSTANCE_SETTINGS_PATH_PREFIX}/`)) {
|
||||
return pathname.slice(INSTANCE_SETTINGS_PATH_PREFIX.length);
|
||||
@@ -61,6 +63,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
|
||||
if (
|
||||
suffix === "/profile" ||
|
||||
suffix === "/general" ||
|
||||
suffix === "/environments" ||
|
||||
suffix === "/access" ||
|
||||
suffix === "/heartbeats" ||
|
||||
suffix === "/plugins" ||
|
||||
|
||||
@@ -194,6 +194,7 @@ export const queryKeys = {
|
||||
mine: (companyId: string) => ["resource-memberships", companyId, "me"] as const,
|
||||
},
|
||||
instance: {
|
||||
settings: ["instance", "settings"] as const,
|
||||
generalSettings: ["instance", "general-settings"] as const,
|
||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
experimentalSettings: ["instance", "experimental-settings"] as const,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
getAdapterEnvironmentSupport,
|
||||
type EnvBinding,
|
||||
type Environment,
|
||||
type EnvironmentProbeResult,
|
||||
type JsonSchema,
|
||||
@@ -12,6 +13,7 @@ import { environmentsApi } from "@/api/environments";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { secretsApi } from "@/api/secrets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EnvVarEditor } from "@/components/EnvVarEditor";
|
||||
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
@@ -37,6 +39,7 @@ type EnvironmentFormState = {
|
||||
sshStrictHostKeyChecking: boolean;
|
||||
sandboxProvider: string;
|
||||
sandboxConfig: Record<string, unknown>;
|
||||
envVars: Record<string, EnvBinding>;
|
||||
};
|
||||
|
||||
const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
|
||||
@@ -49,6 +52,7 @@ function buildEnvironmentPayload(form: EnvironmentFormState) {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
driver: form.driver,
|
||||
envVars: form.envVars,
|
||||
config:
|
||||
form.driver === "ssh"
|
||||
? {
|
||||
@@ -88,9 +92,23 @@ function createEmptyEnvironmentForm(): EnvironmentFormState {
|
||||
sshStrictHostKeyChecking: true,
|
||||
sandboxProvider: "",
|
||||
sandboxConfig: {},
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalEnvironment(environment: Environment | null | undefined) {
|
||||
return environment?.driver === "local";
|
||||
}
|
||||
|
||||
function normalizeNonLocalEnvironmentId(
|
||||
environmentId: string | null | undefined,
|
||||
environments: readonly Environment[],
|
||||
): string {
|
||||
if (!environmentId) return "";
|
||||
const environment = environments.find((candidate) => candidate.id === environmentId) ?? null;
|
||||
return isLocalEnvironment(environment) ? "" : environmentId;
|
||||
}
|
||||
|
||||
function readSshConfig(environment: Environment) {
|
||||
const config = environment.config ?? {};
|
||||
return {
|
||||
@@ -159,7 +177,7 @@ function SupportMark({ supported }: { supported: boolean }) {
|
||||
}
|
||||
|
||||
export function CompanyEnvironments() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -169,11 +187,17 @@ export function CompanyEnvironments() {
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Instance settings", href: "/company/settings/instance/general" },
|
||||
{ label: "Environments" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: instanceSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.settings,
|
||||
queryFn: () => instanceSettingsApi.get(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
@@ -198,6 +222,16 @@ export function CompanyEnvironments() {
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const createSecret = useMutation({
|
||||
mutationFn: (input: { name: string; value: string }) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
||||
return secretsApi.create(selectedCompanyId, input);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (!selectedCompanyId) return;
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const environmentMutation = useMutation({
|
||||
mutationFn: async (form: EnvironmentFormState) => {
|
||||
@@ -230,6 +264,26 @@ export function CompanyEnvironments() {
|
||||
},
|
||||
});
|
||||
|
||||
const defaultEnvironmentMutation = useMutation({
|
||||
mutationFn: async (defaultEnvironmentId: string | null) =>
|
||||
await instanceSettingsApi.update({ defaultEnvironmentId }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.settings });
|
||||
pushToast({
|
||||
title: "Default environment updated",
|
||||
body: "Agent inheritance now follows the updated instance default.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to update default environment",
|
||||
body: error instanceof Error ? error.message : "Default environment update failed.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const environmentProbeMutation = useMutation({
|
||||
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
|
||||
onSuccess: (probe, environmentId) => {
|
||||
@@ -306,6 +360,7 @@ export function CompanyEnvironments() {
|
||||
sshPrivateKeySecretId: ssh.privateKeySecretId,
|
||||
sshKnownHosts: ssh.knownHosts,
|
||||
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
|
||||
envVars: environment.envVars ?? {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -319,6 +374,7 @@ export function CompanyEnvironments() {
|
||||
driver: "sandbox",
|
||||
sandboxProvider: sandbox.provider,
|
||||
sandboxConfig: sandbox.config,
|
||||
envVars: environment.envVars ?? {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -328,6 +384,7 @@ export function CompanyEnvironments() {
|
||||
name: environment.name,
|
||||
description: environment.description ?? "",
|
||||
driver: "local",
|
||||
envVars: environment.envVars ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -396,8 +453,17 @@ export function CompanyEnvironments() {
|
||||
environmentForm.sandboxProvider !== "fake" &&
|
||||
Object.keys(sandboxConfigErrors).length === 0);
|
||||
|
||||
const savedEnvironments = environments ?? [];
|
||||
const nonLocalEnvironments = savedEnvironments.filter((environment) => !isLocalEnvironment(environment));
|
||||
const instanceDefaultEnvironmentId = normalizeNonLocalEnvironmentId(
|
||||
instanceSettings?.defaultEnvironmentId ?? null,
|
||||
savedEnvironments,
|
||||
);
|
||||
const instanceDefaultEnvironment =
|
||||
nonLocalEnvironments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to manage environments.</div>;
|
||||
return <div className="text-sm text-muted-foreground">Select a company context to manage environment secrets and bindings.</div>;
|
||||
}
|
||||
|
||||
if (!environmentsEnabled) {
|
||||
@@ -405,28 +471,59 @@ export function CompanyEnvironments() {
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Environments</h1>
|
||||
<h1 className="text-lg font-semibold">Environments</h1>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
|
||||
Enable Environments in instance experimental settings to manage company execution targets.
|
||||
Enable Environments in instance experimental settings to manage shared execution targets.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6" data-testid="company-settings-environments-section">
|
||||
<div className="max-w-5xl space-y-6" data-testid="instance-settings-environments-section">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Environments</h1>
|
||||
<h1 className="text-lg font-semibold">Environments</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Define reusable execution targets for projects, task workspaces, and remote-capable adapters.
|
||||
Define reusable execution targets for the instance. The built-in default is <span className="font-medium text-foreground">Local</span>; agents inherit that unless the instance default or an agent override points somewhere else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Instance default environment</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
New agent configurations inherit this target unless they explicitly override it.
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[18rem] flex-1">
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
value={instanceDefaultEnvironmentId}
|
||||
onChange={(event) =>
|
||||
defaultEnvironmentMutation.mutate(event.target.value || null)}
|
||||
disabled={defaultEnvironmentMutation.isPending}
|
||||
>
|
||||
<option value="">Local (built-in default)</option>
|
||||
{nonLocalEnvironments.map((environment) => (
|
||||
<option key={environment.id} value={environment.id}>
|
||||
{environment.name} · {environment.driver}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{instanceDefaultEnvironment
|
||||
? `Agents currently inherit ${instanceDefaultEnvironment.name} unless they override it.`
|
||||
: "Agents currently inherit the built-in Local environment unless they override it."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Environment choices use the same adapter support matrix as agent defaults. SSH is always available for
|
||||
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
|
||||
@@ -485,10 +582,10 @@ export function CompanyEnvironments() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(environments ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No environments saved for this company yet.</div>
|
||||
{savedEnvironments.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No saved environments yet. Local remains the default until you add another target.</div>
|
||||
) : (
|
||||
(environments ?? []).map((environment) => {
|
||||
savedEnvironments.map((environment) => {
|
||||
const probe = probeResults[environment.id] ?? null;
|
||||
const isEditing = editingEnvironmentId === environment.id;
|
||||
return (
|
||||
@@ -754,6 +851,19 @@ export function CompanyEnvironments() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Environment variables"
|
||||
hint="Injected into runs that resolve through this environment. Use plain values or company secrets."
|
||||
>
|
||||
<EnvVarEditor
|
||||
value={environmentForm.envVars}
|
||||
secrets={secrets ?? []}
|
||||
onCreateSecret={async (name, value) => await createSecret.mutateAsync({ name, value })}
|
||||
onChange={(env) =>
|
||||
setEnvironmentForm((current) => ({ ...current, envVars: env ?? {} }))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -161,13 +161,13 @@ describe("PluginSettings", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
|
||||
it("routes environment-provider plugins to instance environments when they have no instance config", async () => {
|
||||
const root = await renderSettings(container);
|
||||
|
||||
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
|
||||
expect(container.textContent).toContain("company-scoped instead of instance-global");
|
||||
const link = container.querySelector('a[href="/company/settings/environments"]');
|
||||
expect(link?.textContent).toContain("Open Company Environments");
|
||||
expect(container.textContent).toContain("Configure this plugin from Instance Settings → Environments.");
|
||||
expect(container.textContent).toContain("secret bindings still resolve through the selected company context");
|
||||
const link = container.querySelector('a[href="/company/settings/instance/environments"]');
|
||||
expect(link?.textContent).toContain("Open Environments");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -254,14 +254,14 @@ export function PluginSettings() {
|
||||
/>
|
||||
) : environmentDrivers.length > 0 ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-foreground">Configure this plugin from Company Environments.</p>
|
||||
<p className="font-medium text-foreground">Configure this plugin from Instance Settings → Environments.</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{driverLabel || "This plugin"} registers environment runtime settings there so credentials stay
|
||||
company-scoped instead of instance-global.
|
||||
{driverLabel || "This plugin"} registers environment runtime settings there so the execution target
|
||||
stays instance-scoped while secret bindings still resolve through the selected company context.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link to="/company/settings/environments">
|
||||
<Button variant="outline" size="sm">Open Company Environments</Button>
|
||||
<Link to="/company/settings/instance/environments">
|
||||
<Button variant="outline" size="sm">Open Environments</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -780,7 +780,6 @@ export const ManagementMatrix: Story = {};
|
||||
|
||||
const managedKubernetesEnvironment: Environment = {
|
||||
id: "env-k8s-storybook",
|
||||
companyId: COMPANY_ID,
|
||||
name: "Kubernetes Sandbox",
|
||||
description: "Managed Kubernetes sandbox environment for hosted tenant execution.",
|
||||
driver: "sandbox",
|
||||
@@ -792,6 +791,7 @@ const managedKubernetesEnvironment: Environment = {
|
||||
runtimeClassName: "gvisor",
|
||||
egressMode: "cilium",
|
||||
},
|
||||
envVars: {},
|
||||
metadata: { managedByPaperclip: true, managedKubernetesSandbox: true },
|
||||
createdAt: recent(2_000),
|
||||
updatedAt: recent(60),
|
||||
|
||||
Reference in new issue
Block a user