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:
9
packages/db/src/migrations/0036_cheerful_nitro.sql
Normal file
9
packages/db/src/migrations/0036_cheerful_nitro.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE "instance_settings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"singleton_key" text DEFAULT 'default' NOT NULL,
|
||||||
|
"experimental" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "instance_settings_singleton_key_idx" ON "instance_settings" USING btree ("singleton_key");
|
||||||
10023
packages/db/src/migrations/meta/0036_snapshot.json
Normal file
10023
packages/db/src/migrations/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
|||||||
"when": 1773698696169,
|
"when": 1773698696169,
|
||||||
"tag": "0035_marvelous_satana",
|
"tag": "0035_marvelous_satana",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773756213455,
|
||||||
|
"tag": "0036_cheerful_nitro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { companies } from "./companies.js";
|
export { companies } from "./companies.js";
|
||||||
export { companyLogos } from "./company_logos.js";
|
export { companyLogos } from "./company_logos.js";
|
||||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||||
|
export { instanceSettings } from "./instance_settings.js";
|
||||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||||
export { agents } from "./agents.js";
|
export { agents } from "./agents.js";
|
||||||
export { companyMemberships } from "./company_memberships.js";
|
export { companyMemberships } from "./company_memberships.js";
|
||||||
|
|||||||
15
packages/db/src/schema/instance_settings.ts
Normal file
15
packages/db/src/schema/instance_settings.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const instanceSettings = pgTable(
|
||||||
|
"instance_settings",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
singletonKey: text("singleton_key").notNull().default("default"),
|
||||||
|
experimental: jsonb("experimental").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
singletonKeyIdx: uniqueIndex("instance_settings_singleton_key_idx").on(table.singletonKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -120,6 +120,8 @@ export {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
Company,
|
Company,
|
||||||
|
InstanceExperimentalSettings,
|
||||||
|
InstanceSettings,
|
||||||
Agent,
|
Agent,
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
@@ -239,6 +241,12 @@ export type {
|
|||||||
ProviderQuotaResult,
|
ProviderQuotaResult,
|
||||||
} from "./types/index.js";
|
} from "./types/index.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
instanceExperimentalSettingsSchema,
|
||||||
|
patchInstanceExperimentalSettingsSchema,
|
||||||
|
type PatchInstanceExperimentalSettings,
|
||||||
|
} from "./validators/index.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createCompanySchema,
|
createCompanySchema,
|
||||||
updateCompanySchema,
|
updateCompanySchema,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type { Company } from "./company.js";
|
export type { Company } from "./company.js";
|
||||||
|
export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js";
|
||||||
export type {
|
export type {
|
||||||
Agent,
|
Agent,
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
|
|||||||
10
packages/shared/src/types/instance.ts
Normal file
10
packages/shared/src/types/instance.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface InstanceExperimentalSettings {
|
||||||
|
enableIsolatedWorkspaces: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceSettings {
|
||||||
|
id: string;
|
||||||
|
experimental: InstanceExperimentalSettings;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export {
|
||||||
|
instanceExperimentalSettingsSchema,
|
||||||
|
patchInstanceExperimentalSettingsSchema,
|
||||||
|
type InstanceExperimentalSettings,
|
||||||
|
type PatchInstanceExperimentalSettings,
|
||||||
|
} from "./instance.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
upsertBudgetPolicySchema,
|
upsertBudgetPolicySchema,
|
||||||
resolveBudgetIncidentSchema,
|
resolveBudgetIncidentSchema,
|
||||||
|
|||||||
10
packages/shared/src/validators/instance.ts
Normal file
10
packages/shared/src/validators/instance.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const instanceExperimentalSettingsSchema = z.object({
|
||||||
|
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();
|
||||||
|
|
||||||
|
export type InstanceExperimentalSettings = z.infer<typeof instanceExperimentalSettingsSchema>;
|
||||||
|
export type PatchInstanceExperimentalSettings = z.infer<typeof patchInstanceExperimentalSettingsSchema>;
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildExecutionWorkspaceAdapterConfig,
|
buildExecutionWorkspaceAdapterConfig,
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
|
gateProjectExecutionWorkspacePolicy,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
@@ -140,4 +141,19 @@ describe("execution workspace policy helpers", () => {
|
|||||||
mode: "shared_workspace",
|
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 { activityRoutes } from "./routes/activity.js";
|
||||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||||
|
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
import { accessRoutes } from "./routes/access.js";
|
import { accessRoutes } from "./routes/access.js";
|
||||||
@@ -147,6 +148,7 @@ export async function createApp(
|
|||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
|
api.use(instanceSettingsRoutes(db));
|
||||||
const hostServicesDisposers = new Map<string, () => void>();
|
const hostServicesDisposers = new Map<string, () => void>();
|
||||||
const workerManager = createPluginWorkerManager();
|
const workerManager = createPluginWorkerManager();
|
||||||
const pluginRegistry = pluginRegistryService(db);
|
const pluginRegistry = pluginRegistryService(db);
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export { dashboardRoutes } from "./dashboard.js";
|
|||||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||||
export { llmRoutes } from "./llms.js";
|
export { llmRoutes } from "./llms.js";
|
||||||
export { accessRoutes } from "./access.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 {
|
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
|
||||||
const parsed = parseObject(raw);
|
const parsed = parseObject(raw);
|
||||||
if (Object.keys(parsed).length === 0) return null;
|
if (Object.keys(parsed).length === 0) return null;
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ import { issueService } from "./issues.js";
|
|||||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||||
import {
|
import {
|
||||||
buildExecutionWorkspaceAdapterConfig,
|
buildExecutionWorkspaceAdapterConfig,
|
||||||
|
gateProjectExecutionWorkspacePolicy,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
|
import { instanceSettingsService } from "./instance-settings.js";
|
||||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||||
|
|
||||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
@@ -697,6 +699,8 @@ function resolveNextSessionState(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function heartbeatService(db: Db) {
|
export function heartbeatService(db: Db) {
|
||||||
|
const instanceSettings = instanceSettingsService(db);
|
||||||
|
|
||||||
const runLogStore = getRunLogStore();
|
const runLogStore = getRunLogStore();
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
const issuesSvc = issueService(db);
|
const issuesSvc = issueService(db);
|
||||||
@@ -1661,9 +1665,10 @@ export function heartbeatService(db: Db) {
|
|||||||
issueAssigneeConfig.assigneeAdapterOverrides,
|
issueAssigneeConfig.assigneeAdapterOverrides,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
|
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||||
issueAssigneeConfig?.executionWorkspaceSettings,
|
const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled
|
||||||
);
|
? parseIssueExecutionWorkspaceSettings(issueAssigneeConfig?.executionWorkspaceSettings)
|
||||||
|
: null;
|
||||||
const contextProjectId = readNonEmptyString(context.projectId);
|
const contextProjectId = readNonEmptyString(context.projectId);
|
||||||
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
|
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
|
||||||
const projectExecutionWorkspacePolicy = executionProjectId
|
const projectExecutionWorkspacePolicy = executionProjectId
|
||||||
@@ -1671,7 +1676,11 @@ export function heartbeatService(db: Db) {
|
|||||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
.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;
|
: null;
|
||||||
const taskSession = taskKey
|
const taskSession = taskKey
|
||||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, 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 { dashboardService } from "./dashboard.js";
|
||||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||||
export { accessService } from "./access.js";
|
export { accessService } from "./access.js";
|
||||||
|
export { instanceSettingsService } from "./instance-settings.js";
|
||||||
export { companyPortabilityService } from "./company-portability.js";
|
export { companyPortabilityService } from "./company-portability.js";
|
||||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||||
export { workProductService } from "./work-products.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 { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import {
|
import {
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
|
gateProjectExecutionWorkspacePolicy,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
|
import { instanceSettingsService } from "./instance-settings.js";
|
||||||
import { redactCurrentUserText } from "../log-redaction.js";
|
import { redactCurrentUserText } from "../log-redaction.js";
|
||||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||||
import { getDefaultCompanyGoal } from "./goals.js";
|
import { getDefaultCompanyGoal } from "./goals.js";
|
||||||
@@ -316,6 +318,8 @@ function withActiveRuns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function issueService(db: Db) {
|
export function issueService(db: Db) {
|
||||||
|
const instanceSettings = instanceSettingsService(db);
|
||||||
|
|
||||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||||
const assignee = await db
|
const assignee = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -676,6 +680,12 @@ export function issueService(db: Db) {
|
|||||||
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
|
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
|
||||||
) => {
|
) => {
|
||||||
const { labelIds: inputLabelIds, ...issueData } = data;
|
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) {
|
if (data.assigneeAgentId && data.assigneeUserId) {
|
||||||
throw unprocessable("Issue can only have one assignee");
|
throw unprocessable("Issue can only have one assignee");
|
||||||
}
|
}
|
||||||
@@ -706,7 +716,10 @@ export function issueService(db: Db) {
|
|||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
executionWorkspaceSettings =
|
executionWorkspaceSettings =
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject(
|
defaultIssueExecutionWorkspaceSettingsForProject(
|
||||||
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
gateProjectExecutionWorkspacePolicy(
|
||||||
|
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
|
||||||
|
isolatedWorkspacesEnabled,
|
||||||
|
),
|
||||||
) as Record<string, unknown> | null;
|
) as Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
|
||||||
@@ -779,6 +792,12 @@ export function issueService(db: Db) {
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
const { labelIds: nextLabelIds, ...issueData } = data;
|
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) {
|
if (issueData.status) {
|
||||||
assertTransition(existing.status, issueData.status);
|
assertTransition(existing.status, issueData.status);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Inbox } from "./pages/Inbox";
|
|||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||||
|
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
|
||||||
import { PluginManager } from "./pages/PluginManager";
|
import { PluginManager } from "./pages/PluginManager";
|
||||||
import { PluginSettings } from "./pages/PluginSettings";
|
import { PluginSettings } from "./pages/PluginSettings";
|
||||||
import { PluginPage } from "./pages/PluginPage";
|
import { PluginPage } from "./pages/PluginPage";
|
||||||
@@ -307,6 +308,7 @@ export function App() {
|
|||||||
<Route path="instance/settings" element={<Layout />}>
|
<Route path="instance/settings" element={<Layout />}>
|
||||||
<Route index element={<Navigate to="heartbeats" replace />} />
|
<Route index element={<Navigate to="heartbeats" replace />} />
|
||||||
<Route path="heartbeats" element={<InstanceSettings />} />
|
<Route path="heartbeats" element={<InstanceSettings />} />
|
||||||
|
<Route path="experimental" element={<InstanceExperimentalSettings />} />
|
||||||
<Route path="plugins" element={<PluginManager />} />
|
<Route path="plugins" element={<PluginManager />} />
|
||||||
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export { costsApi } from "./costs";
|
|||||||
export { activityApi } from "./activity";
|
export { activityApi } from "./activity";
|
||||||
export { dashboardApi } from "./dashboard";
|
export { dashboardApi } from "./dashboard";
|
||||||
export { heartbeatsApi } from "./heartbeats";
|
export { heartbeatsApi } from "./heartbeats";
|
||||||
|
export { instanceSettingsApi } from "./instanceSettings";
|
||||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||||
|
|||||||
12
ui/src/api/instanceSettings.ts
Normal file
12
ui/src/api/instanceSettings.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type {
|
||||||
|
InstanceExperimentalSettings,
|
||||||
|
PatchInstanceExperimentalSettings,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const instanceSettingsApi = {
|
||||||
|
getExperimental: () =>
|
||||||
|
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
|
||||||
|
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
|
||||||
|
api.patch<InstanceExperimentalSettings>("/instance/settings/experimental", patch),
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Clock3, Puzzle, Settings } from "lucide-react";
|
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react";
|
||||||
import { NavLink } from "@/lib/router";
|
import { NavLink } from "@/lib/router";
|
||||||
import { pluginsApi } from "@/api/plugins";
|
import { pluginsApi } from "@/api/plugins";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
@@ -23,6 +23,7 @@ export function InstanceSidebar() {
|
|||||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||||
|
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
|
||||||
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||||
{(plugins ?? []).length > 0 ? (
|
{(plugins ?? []).length > 0 ? (
|
||||||
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
|
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -189,6 +190,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId;
|
const currentUserId = session?.user?.id ?? session?.session?.userId;
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
@@ -258,7 +263,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
const currentProject = issue.projectId
|
const currentProject = issue.projectId
|
||||||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
const currentProjectExecutionWorkspacePolicy =
|
||||||
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
|
? currentProject?.executionWorkspacePolicy ?? null
|
||||||
|
: null;
|
||||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||||
const currentExecutionWorkspaceSelection =
|
const currentExecutionWorkspaceSelection =
|
||||||
issue.executionWorkspacePreference
|
issue.executionWorkspacePreference
|
||||||
|
|||||||
@@ -24,32 +24,16 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|||||||
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
||||||
import { healthApi } from "../api/health";
|
import { healthApi } from "../api/health";
|
||||||
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
||||||
|
import {
|
||||||
|
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||||
|
normalizeRememberedInstanceSettingsPath,
|
||||||
|
} from "../lib/instance-settings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||||
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
|
|
||||||
|
|
||||||
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
|
|
||||||
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
|
|
||||||
|
|
||||||
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
|
||||||
const pathname = match?.[1] ?? rawPath;
|
|
||||||
const search = match?.[2] ?? "";
|
|
||||||
const hash = match?.[3] ?? "";
|
|
||||||
|
|
||||||
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
|
|
||||||
return `${pathname}${search}${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
|
|
||||||
return `${pathname}${search}${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_INSTANCE_SETTINGS_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRememberedInstanceSettingsPath(): string {
|
function readRememberedInstanceSettingsPath(): string {
|
||||||
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useDialog } from "../context/DialogContext";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
@@ -341,6 +342,11 @@ export function NewIssueDialog() {
|
|||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
enabled: newIssueOpen,
|
||||||
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
const activeProjects = useMemo(
|
const activeProjects = useMemo(
|
||||||
() => (projects ?? []).filter((p) => !p.archivedAt),
|
() => (projects ?? []).filter((p) => !p.archivedAt),
|
||||||
@@ -635,7 +641,10 @@ export function NewIssueDialog() {
|
|||||||
chrome: assigneeChrome,
|
chrome: assigneeChrome,
|
||||||
});
|
});
|
||||||
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
const selectedProject = orderedProjects.find((project) => project.id === projectId);
|
||||||
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy ?? null;
|
const executionWorkspacePolicy =
|
||||||
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
|
? selectedProject?.executionWorkspacePolicy ?? null
|
||||||
|
: null;
|
||||||
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||||
);
|
);
|
||||||
@@ -743,7 +752,10 @@ export function NewIssueDialog() {
|
|||||||
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
|
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
|
||||||
: null;
|
: null;
|
||||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||||
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
const currentProjectExecutionWorkspacePolicy =
|
||||||
|
experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
|
? currentProject?.executionWorkspacePolicy ?? null
|
||||||
|
: null;
|
||||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
const workspaces = reusableExecutionWorkspaces ?? [];
|
||||||
@@ -1106,9 +1118,9 @@ export function NewIssueDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<div className="px-4 pb-2 shrink-0 space-y-2">
|
<div className="px-4 py-3 shrink-0 space-y-2">
|
||||||
{currentProjectSupportsExecutionWorkspace && (
|
{currentProjectSupportsExecutionWorkspace && (
|
||||||
<div className="rounded-md border border-border px-3 py-2 space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-medium">Execution workspace</div>
|
<div className="text-xs font-medium">Execution workspace</div>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.
|
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Project } from "@paperclipai/shared";
|
|||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
import { cn, formatDate } from "../lib/utils";
|
import { cn, formatDate } from "../lib/utils";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
@@ -173,6 +174,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
|
|
||||||
const linkedGoalIds = project.goalIds.length > 0
|
const linkedGoalIds = project.goalIds.length > 0
|
||||||
? project.goalIds
|
? project.goalIds
|
||||||
@@ -194,6 +199,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||||||
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id);
|
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id);
|
||||||
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
|
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
|
||||||
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
|
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
|
||||||
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||||
const executionWorkspaceDefaultMode =
|
const executionWorkspaceDefaultMode =
|
||||||
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
||||||
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
||||||
@@ -781,244 +787,255 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-4" />
|
{isolatedWorkspacesEnabled ? (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
<div className="py-1.5 space-y-2">
|
<div className="py-1.5 space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>Execution Workspaces</span>
|
<span>Execution Workspaces</span>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
aria-label="Execution workspaces help"
|
aria-label="Execution workspaces help"
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
|
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span>Enable isolated issue checkouts</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Let issues choose between the project’s primary checkout and an isolated execution workspace.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{onUpdate || onFieldUpdate ? (
|
<div className="space-y-3">
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
commitField(
|
|
||||||
"execution_workspace_enabled",
|
|
||||||
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{executionWorkspacesEnabled && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<span>New issues default to isolated checkout</span>
|
<span>Enable isolated issue checkouts</span>
|
||||||
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
|
<SaveIndicator state={fieldState("execution_workspace_enabled")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted-foreground">
|
|
||||||
If disabled, new issues stay on the project’s primary checkout unless someone opts in.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
commitField(
|
|
||||||
"execution_workspace_default_mode",
|
|
||||||
updateExecutionWorkspacePolicy({
|
|
||||||
defaultMode: executionWorkspaceDefaultMode === "isolated_workspace" ? "shared_workspace" : "isolated_workspace",
|
|
||||||
})!,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 w-full py-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
{executionWorkspaceAdvancedOpen ? "Hide advanced checkout settings" : "Show advanced checkout settings"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{executionWorkspaceAdvancedOpen && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
Let issues choose between the project's primary checkout and an isolated execution workspace.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Base ref</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<DraftInput
|
|
||||||
value={executionWorkspaceStrategy.baseRef ?? ""}
|
|
||||||
onCommit={(value) =>
|
|
||||||
commitField("execution_workspace_base_ref", {
|
|
||||||
...updateExecutionWorkspacePolicy({
|
|
||||||
workspaceStrategy: {
|
|
||||||
...executionWorkspaceStrategy,
|
|
||||||
type: "git_worktree",
|
|
||||||
baseRef: value || null,
|
|
||||||
},
|
|
||||||
})!,
|
|
||||||
})}
|
|
||||||
immediate
|
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
||||||
placeholder="origin/main"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Branch template</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<DraftInput
|
|
||||||
value={executionWorkspaceStrategy.branchTemplate ?? ""}
|
|
||||||
onCommit={(value) =>
|
|
||||||
commitField("execution_workspace_branch_template", {
|
|
||||||
...updateExecutionWorkspacePolicy({
|
|
||||||
workspaceStrategy: {
|
|
||||||
...executionWorkspaceStrategy,
|
|
||||||
type: "git_worktree",
|
|
||||||
branchTemplate: value || null,
|
|
||||||
},
|
|
||||||
})!,
|
|
||||||
})}
|
|
||||||
immediate
|
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
||||||
placeholder="{{issue.identifier}}-{{slug}}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Worktree parent dir</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<DraftInput
|
|
||||||
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
|
|
||||||
onCommit={(value) =>
|
|
||||||
commitField("execution_workspace_worktree_parent_dir", {
|
|
||||||
...updateExecutionWorkspacePolicy({
|
|
||||||
workspaceStrategy: {
|
|
||||||
...executionWorkspaceStrategy,
|
|
||||||
type: "git_worktree",
|
|
||||||
worktreeParentDir: value || null,
|
|
||||||
},
|
|
||||||
})!,
|
|
||||||
})}
|
|
||||||
immediate
|
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
||||||
placeholder=".paperclip/worktrees"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Provision command</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<DraftInput
|
|
||||||
value={executionWorkspaceStrategy.provisionCommand ?? ""}
|
|
||||||
onCommit={(value) =>
|
|
||||||
commitField("execution_workspace_provision_command", {
|
|
||||||
...updateExecutionWorkspacePolicy({
|
|
||||||
workspaceStrategy: {
|
|
||||||
...executionWorkspaceStrategy,
|
|
||||||
type: "git_worktree",
|
|
||||||
provisionCommand: value || null,
|
|
||||||
},
|
|
||||||
})!,
|
|
||||||
})}
|
|
||||||
immediate
|
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
||||||
placeholder="bash ./scripts/provision-worktree.sh"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Teardown command</span>
|
|
||||||
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<DraftInput
|
|
||||||
value={executionWorkspaceStrategy.teardownCommand ?? ""}
|
|
||||||
onCommit={(value) =>
|
|
||||||
commitField("execution_workspace_teardown_command", {
|
|
||||||
...updateExecutionWorkspacePolicy({
|
|
||||||
workspaceStrategy: {
|
|
||||||
...executionWorkspaceStrategy,
|
|
||||||
type: "git_worktree",
|
|
||||||
teardownCommand: value || null,
|
|
||||||
},
|
|
||||||
})!,
|
|
||||||
})}
|
|
||||||
immediate
|
|
||||||
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
|
||||||
placeholder="bash ./scripts/teardown-worktree.sh"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
|
|
||||||
future cleanup flows.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{onUpdate || onFieldUpdate ? (
|
||||||
</>
|
<button
|
||||||
)}
|
className={cn(
|
||||||
</div>
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
</div>
|
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
commitField(
|
||||||
|
"execution_workspace_enabled",
|
||||||
|
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executionWorkspacesEnabled ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span>New issues default to isolated checkout</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_default_mode")} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
If disabled, new issues stay on the project's primary checkout unless someone opts in.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||||
|
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
commitField(
|
||||||
|
"execution_workspace_default_mode",
|
||||||
|
updateExecutionWorkspacePolicy({
|
||||||
|
defaultMode:
|
||||||
|
executionWorkspaceDefaultMode === "isolated_workspace"
|
||||||
|
? "shared_workspace"
|
||||||
|
: "isolated_workspace",
|
||||||
|
})!,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||||
|
executionWorkspaceDefaultMode === "isolated_workspace"
|
||||||
|
? "translate-x-4.5"
|
||||||
|
: "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
onClick={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{executionWorkspaceAdvancedOpen
|
||||||
|
? "Hide advanced checkout settings"
|
||||||
|
: "Show advanced checkout settings"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executionWorkspaceAdvancedOpen ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Base ref</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_base_ref")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DraftInput
|
||||||
|
value={executionWorkspaceStrategy.baseRef ?? ""}
|
||||||
|
onCommit={(value) =>
|
||||||
|
commitField("execution_workspace_base_ref", {
|
||||||
|
...updateExecutionWorkspacePolicy({
|
||||||
|
workspaceStrategy: {
|
||||||
|
...executionWorkspaceStrategy,
|
||||||
|
type: "git_worktree",
|
||||||
|
baseRef: value || null,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
})}
|
||||||
|
immediate
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||||
|
placeholder="origin/main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Branch template</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_branch_template")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DraftInput
|
||||||
|
value={executionWorkspaceStrategy.branchTemplate ?? ""}
|
||||||
|
onCommit={(value) =>
|
||||||
|
commitField("execution_workspace_branch_template", {
|
||||||
|
...updateExecutionWorkspacePolicy({
|
||||||
|
workspaceStrategy: {
|
||||||
|
...executionWorkspaceStrategy,
|
||||||
|
type: "git_worktree",
|
||||||
|
branchTemplate: value || null,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
})}
|
||||||
|
immediate
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||||
|
placeholder="{{issue.identifier}}-{{slug}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Worktree parent dir</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_worktree_parent_dir")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DraftInput
|
||||||
|
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
|
||||||
|
onCommit={(value) =>
|
||||||
|
commitField("execution_workspace_worktree_parent_dir", {
|
||||||
|
...updateExecutionWorkspacePolicy({
|
||||||
|
workspaceStrategy: {
|
||||||
|
...executionWorkspaceStrategy,
|
||||||
|
type: "git_worktree",
|
||||||
|
worktreeParentDir: value || null,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
})}
|
||||||
|
immediate
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||||
|
placeholder=".paperclip/worktrees"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Provision command</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_provision_command")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DraftInput
|
||||||
|
value={executionWorkspaceStrategy.provisionCommand ?? ""}
|
||||||
|
onCommit={(value) =>
|
||||||
|
commitField("execution_workspace_provision_command", {
|
||||||
|
...updateExecutionWorkspacePolicy({
|
||||||
|
workspaceStrategy: {
|
||||||
|
...executionWorkspaceStrategy,
|
||||||
|
type: "git_worktree",
|
||||||
|
provisionCommand: value || null,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
})}
|
||||||
|
immediate
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||||
|
placeholder="bash ./scripts/provision-worktree.sh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Teardown command</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_teardown_command")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DraftInput
|
||||||
|
value={executionWorkspaceStrategy.teardownCommand ?? ""}
|
||||||
|
onCommit={(value) =>
|
||||||
|
commitField("execution_workspace_teardown_command", {
|
||||||
|
...updateExecutionWorkspacePolicy({
|
||||||
|
workspaceStrategy: {
|
||||||
|
...executionWorkspaceStrategy,
|
||||||
|
type: "git_worktree",
|
||||||
|
teardownCommand: value || null,
|
||||||
|
},
|
||||||
|
})!,
|
||||||
|
})}
|
||||||
|
immediate
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
|
||||||
|
placeholder="bash ./scripts/teardown-worktree.sh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Provision runs inside the derived worktree before agent execution. Teardown is stored here for
|
||||||
|
future cleanup flows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
23
ui/src/lib/instance-settings.test.ts
Normal file
23
ui/src/lib/instance-settings.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||||
|
normalizeRememberedInstanceSettingsPath,
|
||||||
|
} from "./instance-settings";
|
||||||
|
|
||||||
|
describe("normalizeRememberedInstanceSettingsPath", () => {
|
||||||
|
it("keeps known instance settings pages", () => {
|
||||||
|
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
|
||||||
|
"/instance/settings/experimental",
|
||||||
|
);
|
||||||
|
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
|
||||||
|
"/instance/settings/plugins/example?tab=config#logs",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default page for unknown paths", () => {
|
||||||
|
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/nope")).toBe(
|
||||||
|
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||||
|
);
|
||||||
|
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
ui/src/lib/instance-settings.ts
Normal file
24
ui/src/lib/instance-settings.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
|
||||||
|
|
||||||
|
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
|
||||||
|
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
|
||||||
|
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
||||||
|
const pathname = match?.[1] ?? rawPath;
|
||||||
|
const search = match?.[2] ?? "";
|
||||||
|
const hash = match?.[3] ?? "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathname === "/instance/settings/heartbeats" ||
|
||||||
|
pathname === "/instance/settings/plugins" ||
|
||||||
|
pathname === "/instance/settings/experimental"
|
||||||
|
) {
|
||||||
|
return `${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
|
||||||
|
return `${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
instance: {
|
instance: {
|
||||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||||
|
experimentalSettings: ["instance", "experimental-settings"] as const,
|
||||||
},
|
},
|
||||||
health: ["health"] as const,
|
health: ["health"] as const,
|
||||||
secrets: {
|
secrets: {
|
||||||
|
|||||||
102
ui/src/pages/InstanceExperimentalSettings.tsx
Normal file
102
ui/src/pages/InstanceExperimentalSettings.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { FlaskConical } from "lucide-react";
|
||||||
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export function InstanceExperimentalSettings() {
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: "Instance Settings" },
|
||||||
|
{ label: "Experimental" },
|
||||||
|
]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const experimentalQuery = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async (enabled: boolean) =>
|
||||||
|
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setActionError(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (experimentalQuery.isLoading) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (experimentalQuery.error) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{experimentalQuery.error instanceof Error
|
||||||
|
? experimentalQuery.error.message
|
||||||
|
: "Failed to load experimental settings."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Experimental</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Opt into features that are still being evaluated before they become default behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
|
||||||
|
and existing issue runs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle isolated workspaces experimental setting"
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
|
||||||
|
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user