Add instance experimental setting for isolated workspaces
Introduce a singleton instance_settings store and experimental settings API, add the Experimental instance settings page, and gate execution workspace behavior behind the new enableIsolatedWorkspaces flag. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
@@ -140,4 +141,19 @@ describe("execution workspace policy helpers", () => {
|
||||
mode: "shared_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables project execution workspace policy when the instance flag is off", () => {
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
{ enabled: true, defaultMode: "isolated_workspace" },
|
||||
true,
|
||||
),
|
||||
).toEqual({ enabled: true, defaultMode: "isolated_workspace" });
|
||||
});
|
||||
});
|
||||
|
||||
99
server/src/__tests__/instance-settings-routes.test.ts
Normal file
99
server/src/__tests__/instance-settings-routes.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
|
||||
|
||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
updateExperimental: vi.fn(),
|
||||
listCompanyIds: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", instanceSettingsRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("instance settings routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
});
|
||||
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
experimental: {
|
||||
enableIsolatedWorkspaces: true,
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
||||
});
|
||||
|
||||
it("allows local board users to read and update experimental settings", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false });
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIsolatedWorkspaces: true,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects non-admin board users", async () => {
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
|
||||
const res = await request(app).get("/api/instance/settings/experimental");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const app = createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableIsolatedWorkspaces: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
@@ -147,6 +148,7 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
|
||||
@@ -12,3 +12,4 @@ export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
|
||||
59
server/src/routes/instance-settings.ts
Normal file
59
server/src/routes/instance-settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { patchInstanceExperimentalSettingsSchema } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { instanceSettingsService, logActivity } from "../services/index.js";
|
||||
import { getActorInfo } from "./authz.js";
|
||||
|
||||
function assertCanManageInstanceSettings(req: Request) {
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden("Board access required");
|
||||
}
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
return;
|
||||
}
|
||||
throw forbidden("Instance admin access required");
|
||||
}
|
||||
|
||||
export function instanceSettingsRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = instanceSettingsService(db);
|
||||
|
||||
router.get("/instance/settings/experimental", async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
res.json(await svc.getExperimental());
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/instance/settings/experimental",
|
||||
validate(patchInstanceExperimentalSettingsSchema),
|
||||
async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
const updated = await svc.updateExperimental(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.experimental_updated",
|
||||
entityType: "instance_settings",
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
experimental: updated.experimental,
|
||||
changedKeys: Object.keys(req.body).sort(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
res.json(updated.experimental);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -77,6 +77,14 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
};
|
||||
}
|
||||
|
||||
export function gateProjectExecutionWorkspacePolicy(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
isolatedWorkspacesEnabled: boolean,
|
||||
): ProjectExecutionWorkspacePolicy | null {
|
||||
if (!isolatedWorkspacesEnabled) return null;
|
||||
return projectPolicy;
|
||||
}
|
||||
|
||||
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
|
||||
@@ -40,10 +40,12 @@ import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
parseIssueExecutionWorkspaceSettings,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
resolveExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
@@ -697,6 +699,8 @@ function resolveNextSessionState(input: {
|
||||
}
|
||||
|
||||
export function heartbeatService(db: Db) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
|
||||
const runLogStore = getRunLogStore();
|
||||
const secretsSvc = secretService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
@@ -1661,9 +1665,10 @@ export function heartbeatService(db: Db) {
|
||||
issueAssigneeConfig.assigneeAdapterOverrides,
|
||||
)
|
||||
: null;
|
||||
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
|
||||
issueAssigneeConfig?.executionWorkspaceSettings,
|
||||
);
|
||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||
const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled
|
||||
? parseIssueExecutionWorkspaceSettings(issueAssigneeConfig?.executionWorkspaceSettings)
|
||||
: null;
|
||||
const contextProjectId = readNonEmptyString(context.projectId);
|
||||
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
|
||||
const projectExecutionWorkspacePolicy = executionProjectId
|
||||
@@ -1671,7 +1676,11 @@ export function heartbeatService(db: Db) {
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
||||
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||
.then((rows) =>
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
))
|
||||
: null;
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
|
||||
@@ -16,6 +16,7 @@ export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
|
||||
95
server/src/services/instance-settings.ts
Normal file
95
server/src/services/instance-settings.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
import {
|
||||
instanceExperimentalSettingsSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type InstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const DEFAULT_SINGLETON_KEY = "default";
|
||||
|
||||
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
|
||||
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableIsolatedWorkspaces: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
experimental: normalizeExperimentalSettings(row.experimental),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function instanceSettingsService(db: Db) {
|
||||
async function getOrCreateRow() {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const now = new Date();
|
||||
const [created] = await db
|
||||
.insert(instanceSettings)
|
||||
.values({
|
||||
singletonKey: DEFAULT_SINGLETON_KEY,
|
||||
experimental: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [instanceSettings.singletonKey],
|
||||
set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
return {
|
||||
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
|
||||
const row = await getOrCreateRow();
|
||||
return normalizeExperimentalSettings(row.experimental);
|
||||
},
|
||||
|
||||
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const nextExperimental = normalizeExperimentalSettings({
|
||||
...normalizeExperimentalSettings(current.experimental),
|
||||
...patch,
|
||||
});
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
experimental: { ...nextExperimental },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
listCompanyIds: async (): Promise<string[]> =>
|
||||
db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.then((rows) => rows.map((row) => row.id)),
|
||||
};
|
||||
}
|
||||
@@ -23,8 +23,10 @@ import { extractProjectMentionIds } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
@@ -316,6 +318,8 @@ function withActiveRuns(
|
||||
}
|
||||
|
||||
export function issueService(db: Db) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
|
||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||
const assignee = await db
|
||||
.select({
|
||||
@@ -676,6 +680,12 @@ export function issueService(db: Db) {
|
||||
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
|
||||
) => {
|
||||
const { labelIds: inputLabelIds, ...issueData } = data;
|
||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||
if (!isolatedWorkspacesEnabled) {
|
||||
delete issueData.executionWorkspaceId;
|
||||
delete issueData.executionWorkspacePreference;
|
||||
delete issueData.executionWorkspaceSettings;
|
||||
}
|
||||
if (data.assigneeAgentId && data.assigneeUserId) {
|
||||
throw unprocessable("Issue can only have one assignee");
|
||||
}
|
||||
@@ -706,7 +716,10 @@ export function issueService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
executionWorkspaceSettings =
|
||||
defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
||||
gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
),
|
||||
) as Record<string, unknown> | null;
|
||||
}
|
||||
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
||||
@@ -779,6 +792,12 @@ export function issueService(db: Db) {
|
||||
if (!existing) return null;
|
||||
|
||||
const { labelIds: nextLabelIds, ...issueData } = data;
|
||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||
if (!isolatedWorkspacesEnabled) {
|
||||
delete issueData.executionWorkspaceId;
|
||||
delete issueData.executionWorkspacePreference;
|
||||
delete issueData.executionWorkspaceSettings;
|
||||
}
|
||||
|
||||
if (issueData.status) {
|
||||
assertTransition(existing.status, issueData.status);
|
||||
|
||||
Reference in New Issue
Block a user