Add project-first execution workspace policies

This commit is contained in:
Dotta
2026-03-10 09:03:31 -05:00
parent 3120c72372
commit b83a87f42f
22 changed files with 7046 additions and 114 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "issues" ADD COLUMN "execution_workspace_settings" jsonb;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "execution_workspace_policy" jsonb;

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,13 @@
"when": 1773089625430,
"tag": "0026_lying_pete_wisdom",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
}
]
}

View File

@@ -40,6 +40,7 @@ export const issues = pgTable(
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp, date, index } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
@@ -15,6 +15,7 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -78,6 +78,11 @@ export type {
ProjectGoalRef,
ProjectWorkspace,
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
Issue,
IssueAssigneeAdapterOverrides,
IssueComment,
@@ -157,9 +162,11 @@ export {
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,

View File

@@ -11,7 +11,14 @@ export type {
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { WorkspaceRuntimeService } from "./workspace-runtime.js";
export type {
WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
ProjectExecutionWorkspacePolicy,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
export type {
Issue,
IssueAssigneeAdapterOverrides,

View File

@@ -1,6 +1,7 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { Goal } from "./goal.js";
import type { Project, ProjectWorkspace } from "./project.js";
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
export interface IssueAncestorProject {
id: string;
@@ -73,6 +74,7 @@ export interface Issue {
requestDepth: number;
billingCode: string | null;
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
startedAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;

View File

@@ -1,5 +1,5 @@
import type { ProjectStatus } from "../constants.js";
import type { WorkspaceRuntimeService } from "./workspace-runtime.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
export interface ProjectGoalRef {
id: string;
@@ -35,6 +35,7 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
archivedAt: Date | null;

View File

@@ -1,3 +1,31 @@
export type ExecutionWorkspaceStrategyType = "project_primary" | "git_worktree";
export type ExecutionWorkspaceMode = "inherit" | "project_primary" | "isolated" | "agent_default";
export interface ExecutionWorkspaceStrategy {
type: ExecutionWorkspaceStrategyType;
baseRef?: string | null;
branchTemplate?: string | null;
worktreeParentDir?: string | null;
}
export interface ProjectExecutionWorkspacePolicy {
enabled: boolean;
defaultMode?: "project_primary" | "isolated";
allowIssueOverride?: boolean;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
branchPolicy?: Record<string, unknown> | null;
pullRequestPolicy?: Record<string, unknown> | null;
cleanupPolicy?: Record<string, unknown> | null;
}
export interface IssueExecutionWorkspaceSettings {
mode?: ExecutionWorkspaceMode;
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
workspaceRuntime?: Record<string, unknown> | null;
}
export interface WorkspaceRuntimeService {
id: string;
companyId: string;

View File

@@ -49,16 +49,19 @@ export {
updateProjectSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
type CreateProject,
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
type ProjectExecutionWorkspacePolicy,
} from "./project.js";
export {
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
@@ -66,6 +69,7 @@ export {
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
type IssueExecutionWorkspaceSettings,
type CheckoutIssue,
type AddIssueComment,
type LinkIssueApproval,

View File

@@ -1,6 +1,23 @@
import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
})
.strict();
export const issueExecutionWorkspaceSettingsSchema = z
.object({
mode: z.enum(["inherit", "project_primary", "isolated", "agent_default"]).optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
})
.strict();
export const issueAssigneeAdapterOverridesSchema = z
.object({
adapterConfig: z.record(z.unknown()).optional(),
@@ -21,6 +38,7 @@ export const createIssueSchema = z.object({
requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(),
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
});
@@ -39,6 +57,7 @@ export const updateIssueSchema = createIssueSchema.partial().extend({
});
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
export const checkoutIssueSchema = z.object({
agentId: z.string().uuid(),

View File

@@ -1,6 +1,28 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
const executionWorkspaceStrategySchema = z
.object({
type: z.enum(["project_primary", "git_worktree"]).optional(),
baseRef: z.string().optional().nullable(),
branchTemplate: z.string().optional().nullable(),
worktreeParentDir: z.string().optional().nullable(),
})
.strict();
export const projectExecutionWorkspacePolicySchema = z
.object({
enabled: z.boolean(),
defaultMode: z.enum(["project_primary", "isolated"]).optional(),
allowIssueOverride: z.boolean().optional(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(),
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
})
.strict();
const projectWorkspaceFields = {
name: z.string().min(1).optional(),
cwd: z.string().min(1).optional().nullable(),
@@ -43,6 +65,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
};
@@ -56,3 +79,5 @@ export type CreateProject = z.infer<typeof createProjectSchema>;
export const updateProjectSchema = z.object(projectFields).partial();
export type UpdateProject = z.infer<typeof updateProjectSchema>;
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
buildExecutionWorkspaceAdapterConfig,
defaultIssueExecutionWorkspaceSettingsForProject,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "../services/execution-workspace-policy.ts";
describe("execution workspace policy helpers", () => {
it("defaults new issue settings from enabled project policy", () => {
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "isolated",
}),
).toEqual({ mode: "isolated" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
}),
).toEqual({ mode: "project_primary" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
expect(
resolveExecutionWorkspaceMode({
projectPolicy: null,
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("agent_default");
});
it("applies project policy strategy and runtime defaults when isolation is enabled", () => {
const result = buildExecutionWorkspaceAdapterConfig({
agentConfig: {
workspaceStrategy: { type: "project_primary" },
},
projectPolicy: {
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
},
workspaceRuntime: {
services: [{ name: "web", command: "pnpm dev" }],
},
},
issueSettings: null,
mode: "isolated",
legacyUseProjectWorkspace: null,
});
expect(result.workspaceStrategy).toEqual({
type: "git_worktree",
baseRef: "origin/main",
});
expect(result.workspaceRuntime).toEqual({
services: [{ name: "web", command: "pnpm dev" }],
});
});
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
const baseConfig = {
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
workspaceRuntime: { services: [{ name: "web" }] },
};
expect(
buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
legacyUseProjectWorkspace: null,
}).workspaceStrategy,
).toBeUndefined();
const agentDefault = buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: null,
issueSettings: { mode: "agent_default" },
mode: "agent_default",
legacyUseProjectWorkspace: null,
});
expect(agentDefault.workspaceStrategy).toBeUndefined();
expect(agentDefault.workspaceRuntime).toBeUndefined();
});
it("parses persisted JSON payloads into typed project and issue workspace settings", () => {
expect(
parseProjectExecutionWorkspacePolicy({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
},
}),
).toEqual({
enabled: true,
defaultMode: "isolated",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
},
});
expect(
parseIssueExecutionWorkspaceSettings({
mode: "project_primary",
}),
).toEqual({
mode: "project_primary",
});
});
});

View File

@@ -0,0 +1,141 @@
import type {
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
IssueExecutionWorkspaceSettings,
ProjectExecutionWorkspacePolicy,
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
return { ...value };
}
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
const parsed = parseObject(raw);
const type = asString(parsed.type, "");
if (type !== "project_primary" && type !== "git_worktree") {
return null;
}
return {
type,
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
};
}
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
const defaultMode = asString(parsed.defaultMode, "");
const allowIssueOverride =
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
return {
enabled,
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
: {}),
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
: {}),
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
: {}),
};
}
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const mode = asString(parsed.mode, "");
return {
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
? { mode }
: {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
: {}),
};
}
export function defaultIssueExecutionWorkspaceSettingsForProject(
projectPolicy: ProjectExecutionWorkspacePolicy | null,
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
return {
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
};
}
export function resolveExecutionWorkspaceMode(input: {
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
legacyUseProjectWorkspace: boolean | null;
}): ParsedExecutionWorkspaceMode {
const issueMode = input.issueSettings?.mode;
if (issueMode && issueMode !== "inherit") {
return issueMode;
}
if (input.projectPolicy?.enabled) {
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
}
if (input.legacyUseProjectWorkspace === false) {
return "agent_default";
}
return "project_primary";
}
export function buildExecutionWorkspaceAdapterConfig(input: {
agentConfig: Record<string, unknown>;
projectPolicy: ProjectExecutionWorkspacePolicy | null;
issueSettings: IssueExecutionWorkspaceSettings | null;
mode: ParsedExecutionWorkspaceMode;
legacyUseProjectWorkspace: boolean | null;
}): Record<string, unknown> {
const nextConfig = { ...input.agentConfig };
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
const issueHasWorkspaceOverrides = Boolean(
input.issueSettings?.mode ||
input.issueSettings?.workspaceStrategy ||
input.issueSettings?.workspaceRuntime,
);
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
if (hasWorkspaceControl) {
if (input.mode === "isolated") {
const strategy =
input.issueSettings?.workspaceStrategy ??
input.projectPolicy?.workspaceStrategy ??
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
} else {
delete nextConfig.workspaceStrategy;
}
if (input.mode === "agent_default") {
delete nextConfig.workspaceRuntime;
} else if (input.issueSettings?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
} else if (input.projectPolicy?.workspaceRuntime) {
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
}
}
return nextConfig;
}

View File

@@ -11,6 +11,7 @@ import {
heartbeatRuns,
costEvents,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
import { conflict, notFound } from "../errors.js";
@@ -31,6 +32,12 @@ import {
releaseRuntimeServicesForRun,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import {
buildExecutionWorkspaceAdapterConfig,
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -1080,8 +1087,10 @@ export function heartbeatService(db: Db) {
const issueAssigneeConfig = issueId
? await db
.select({
projectId: issues.projectId,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
@@ -1093,6 +1102,18 @@ export function heartbeatService(db: Db) {
issueAssigneeConfig.assigneeAdapterOverrides,
)
: null;
const issueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings(
issueAssigneeConfig?.executionWorkspaceSettings,
);
const contextProjectId = readNonEmptyString(context.projectId);
const executionProjectId = issueAssigneeConfig?.projectId ?? contextProjectId;
const projectExecutionWorkspacePolicy = executionProjectId
? await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
: null;
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
@@ -1102,16 +1123,28 @@ export function heartbeatService(db: Db) {
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
);
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const resolvedWorkspace = await resolveWorkspaceForRun(
agent,
context,
previousSessionParams,
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
{ useProjectWorkspace: executionWorkspaceMode !== "agent_default" },
);
const config = parseObject(agent.adapterConfig);
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
mode: executionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...config, ...issueAssigneeOverrides.adapterConfig }
: config;
? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
: workspaceManagedConfig;
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
mergedConfig,
@@ -1168,6 +1201,7 @@ export function heartbeatService(db: Db) {
context.paperclipWorkspace = {
cwd: executionWorkspace.cwd,
source: executionWorkspace.source,
mode: executionWorkspaceMode,
strategy: executionWorkspace.strategy,
projectId: executionWorkspace.projectId,
workspaceId: executionWorkspace.workspaceId,

View File

@@ -18,6 +18,10 @@ import {
} from "@paperclipai/db";
import { extractProjectMentionIds } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -635,6 +639,19 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
const project = await tx
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
executionWorkspaceSettings =
defaultIssueExecutionWorkspaceSettingsForProject(
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
) as Record<string, unknown> | null;
}
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
@@ -644,7 +661,13 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
const values = {
...issueData,
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
identifier,
} as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}

View File

@@ -6,11 +6,13 @@ import {
deriveProjectUrlKey,
isUuidLike,
normalizeProjectUrlKey,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
type ProjectWorkspace,
type WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -26,10 +28,11 @@ type CreateWorkspaceInput = {
};
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
interface ProjectWithGoals extends ProjectRow {
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
urlKey: string;
goalIds: string[];
goals: ProjectGoalRef[];
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
}
@@ -77,6 +80,7 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
urlKey: deriveProjectUrlKey(r.name, r.id),
goalIds: g.map((x) => x.id),
goals: g,
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
} as ProjectWithGoals;
});
}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import type { AdapterConfigFieldsProps } from "./types";
import { DraftInput, Field, help } from "../components/agent-config-primitives";
import { CollapsibleSection, DraftInput, Field, help } from "../components/agent-config-primitives";
import { RuntimeServicesJsonField } from "./runtime-json-fields";
const inputClass =
@@ -48,6 +49,7 @@ export function LocalWorkspaceRuntimeFields({
config,
mark,
}: AdapterConfigFieldsProps) {
const [open, setOpen] = useState(false);
const existing = readWorkspaceStrategy(config);
const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type;
const updateEditWorkspaceStrategy = (patch: Partial<typeof existing>) => {
@@ -62,75 +64,81 @@ export function LocalWorkspaceRuntimeFields({
);
};
return (
<>
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
<select
className={inputClass}
value={strategyType}
onChange={(e) => {
const nextType = e.target.value;
if (isCreate) {
set!({ workspaceStrategyType: nextType });
} else {
updateEditWorkspaceStrategy({ type: nextType });
}
}}
>
<option value="project_primary">Project primary workspace</option>
<option value="git_worktree">Git worktree</option>
</select>
</Field>
<CollapsibleSection
title="Advanced Workspace Overrides"
open={open}
onToggle={() => setOpen((value) => !value)}
>
<div className="space-y-3 pt-1">
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
<select
className={inputClass}
value={strategyType}
onChange={(e) => {
const nextType = e.target.value;
if (isCreate) {
set!({ workspaceStrategyType: nextType });
} else {
updateEditWorkspaceStrategy({ type: nextType });
}
}}
>
<option value="project_primary">Project primary workspace</option>
<option value="git_worktree">Git worktree</option>
</select>
</Field>
{strategyType === "git_worktree" && (
<>
<Field label="Base ref" hint={help.workspaceBaseRef}>
<DraftInput
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
onCommit={(v) =>
isCreate
? set!({ workspaceBaseRef: v })
: updateEditWorkspaceStrategy({ baseRef: v || "" })
}
immediate
className={inputClass}
placeholder="origin/main"
/>
</Field>
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
<DraftInput
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
onCommit={(v) =>
isCreate
? set!({ workspaceBranchTemplate: v })
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
}
immediate
className={inputClass}
placeholder="{{issue.identifier}}-{{slug}}"
/>
</Field>
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
<DraftInput
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
onCommit={(v) =>
isCreate
? set!({ worktreeParentDir: v })
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
}
immediate
className={inputClass}
placeholder=".paperclip/worktrees"
/>
</Field>
</>
)}
<RuntimeServicesJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
</>
{strategyType === "git_worktree" && (
<>
<Field label="Base ref" hint={help.workspaceBaseRef}>
<DraftInput
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
onCommit={(v) =>
isCreate
? set!({ workspaceBaseRef: v })
: updateEditWorkspaceStrategy({ baseRef: v || "" })
}
immediate
className={inputClass}
placeholder="origin/main"
/>
</Field>
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
<DraftInput
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
onCommit={(v) =>
isCreate
? set!({ workspaceBranchTemplate: v })
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
}
immediate
className={inputClass}
placeholder="{{issue.identifier}}-{{slug}}"
/>
</Field>
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
<DraftInput
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
onCommit={(v) =>
isCreate
? set!({ worktreeParentDir: v })
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
}
immediate
className={inputClass}
placeholder=".paperclip/worktrees"
/>
</Field>
</>
)}
<RuntimeServicesJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
</div>
</CollapsibleSection>
);
}

View File

@@ -176,6 +176,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8);
};
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
? true
: issue.executionWorkspaceSettings?.mode === "project_primary"
? false
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
@@ -402,7 +412,10 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
onClick={() => {
onUpdate({ projectId: null, executionWorkspaceSettings: null });
setProjectOpen(false);
}}
>
No project
</button>
@@ -419,7 +432,15 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
onClick={() => {
onUpdate({
projectId: p.id,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
@@ -504,6 +525,42 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{projectContent}
</PropertyPicker>
{currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace">
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full">
<div className="min-w-0">
<div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
</div>
<div className="text-[11px] text-muted-foreground">
Toggle whether this issue runs in its own execution workspace.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
onUpdate({
executionWorkspaceSettings: {
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
},
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</PropertyRow>
)}
{issue.parentId && (
<PropertyRow label="Parent">
<Link

View File

@@ -65,7 +65,7 @@ interface IssueDraft {
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
assigneeUseProjectWorkspace: boolean;
useIsolatedExecutionWorkspace: boolean;
}
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
@@ -99,7 +99,6 @@ function buildAssigneeAdapterOverrides(input: {
modelOverride: string;
thinkingEffortOverride: string;
chrome: boolean;
useProjectWorkspace: boolean;
}): Record<string, unknown> | null {
const adapterType = input.adapterType ?? null;
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
@@ -127,9 +126,6 @@ function buildAssigneeAdapterOverrides(input: {
if (Object.keys(adapterConfig).length > 0) {
overrides.adapterConfig = adapterConfig;
}
if (!input.useProjectWorkspace) {
overrides.useProjectWorkspace = false;
}
return Object.keys(overrides).length > 0 ? overrides : null;
}
@@ -180,10 +176,11 @@ export function NewIssueDialog() {
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
@@ -300,7 +297,7 @@ export function NewIssueDialog() {
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
useIsolatedExecutionWorkspace,
});
}, [
title,
@@ -312,7 +309,7 @@ export function NewIssueDialog() {
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
assigneeUseProjectWorkspace,
useIsolatedExecutionWorkspace,
newIssueOpen,
scheduleSave,
]);
@@ -321,6 +318,7 @@ export function NewIssueDialog() {
useEffect(() => {
if (!newIssueOpen) return;
setDialogCompanyId(selectedCompanyId);
executionWorkspaceDefaultProjectId.current = null;
const draft = loadDraft();
if (newIssueDefaults.title) {
@@ -333,7 +331,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setUseIsolatedExecutionWorkspace(false);
} else if (draft && draft.title.trim()) {
setTitle(draft.title);
setDescription(draft.description);
@@ -344,7 +342,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
} else {
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
@@ -353,7 +351,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setUseIsolatedExecutionWorkspace(false);
}
}, [newIssueOpen, newIssueDefaults]);
@@ -363,7 +361,6 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
return;
}
@@ -396,10 +393,11 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setUseIsolatedExecutionWorkspace(false);
setExpanded(false);
setDialogCompanyId(null);
setCompanyOpen(false);
executionWorkspaceDefaultProjectId.current = null;
}
function handleCompanyChange(companyId: string) {
@@ -410,7 +408,7 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
setUseIsolatedExecutionWorkspace(false);
}
function discardDraft() {
@@ -426,8 +424,14 @@ export function NewIssueDialog() {
modelOverride: assigneeModelOverride,
thinkingEffortOverride: assigneeThinkingEffort,
chrome: assigneeChrome,
useProjectWorkspace: assigneeUseProjectWorkspace,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = selectedProject?.executionWorkspacePolicy;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? {
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
}
: null;
createIssue.mutate({
companyId: effectiveCompanyId,
title: title.trim(),
@@ -437,6 +441,7 @@ export function NewIssueDialog() {
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
...(projectId ? { projectId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
});
}
@@ -467,6 +472,8 @@ export function NewIssueDialog() {
const currentPriority = priorities.find((p) => p.value === priority);
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
@@ -503,6 +510,26 @@ export function NewIssueDialog() {
})),
[orderedProjects],
);
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
const policy = nextProject?.executionWorkspacePolicy;
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
}, [orderedProjects]);
useEffect(() => {
if (!newIssueOpen || !projectId || executionWorkspaceDefaultProjectId.current === projectId) {
return;
}
const project = orderedProjects.find((entry) => entry.id === projectId);
if (!project) return;
executionWorkspaceDefaultProjectId.current = projectId;
setUseIsolatedExecutionWorkspace(
Boolean(project.executionWorkspacePolicy?.enabled && project.executionWorkspacePolicy.defaultMode === "isolated"),
);
}, [newIssueOpen, orderedProjects, projectId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() => {
return [...(assigneeAdapterModels ?? [])]
@@ -705,7 +732,7 @@ export function NewIssueDialog() {
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={setProjectId}
onChange={handleProjectChange}
onConfirm={() => {
descriptionEditorRef.current?.focus();
}}
@@ -740,6 +767,34 @@ export function NewIssueDialog() {
</div>
</div>
{currentProjectSupportsExecutionWorkspace && (
<div className="px-4 pb-2 shrink-0">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div className="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground">
Create an issue-specific execution workspace instead of using the project's primary checkout.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
type="button"
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</div>
)}
{supportsAssigneeOverrides && (
<div className="px-4 pb-2 shrink-0">
<button
@@ -800,23 +855,6 @@ export function NewIssueDialog() {
</button>
</div>
)}
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Use project workspace</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
</div>
)}
</div>

View File

@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { CollapsibleSection, DraftInput } from "./agent-config-primitives";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -80,6 +81,7 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false);
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
@@ -106,6 +108,16 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
const workspaces = project.workspaces ?? [];
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const executionWorkspaceDefaultMode =
executionWorkspacePolicy?.defaultMode === "isolated" ? "isolated" : "project_primary";
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
type: "git_worktree",
baseRef: "",
branchTemplate: "",
worktreeParentDir: "",
};
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
@@ -146,6 +158,19 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
setGoalOpen(false);
};
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate) return;
onUpdate({
executionWorkspacePolicy: {
enabled: executionWorkspacesEnabled,
defaultMode: executionWorkspaceDefaultMode,
allowIssueOverride: executionWorkspacePolicy?.allowIssueOverride ?? true,
...executionWorkspacePolicy,
...patch,
},
});
};
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const isGitHubRepoUrl = (value: string) => {
@@ -346,6 +371,162 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
<Separator />
<div className="space-y-1">
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Execution Workspaces</span>
<Tooltip>
<TooltipTrigger asChild>
<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"
aria-label="Execution workspaces help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Project-owned defaults for isolated issue checkouts and execution workspace behavior.
</TooltipContent>
</Tooltip>
</div>
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">Enable isolated issue checkouts</div>
<div className="text-xs text-muted-foreground">
Let issues choose between the projects primary checkout and an isolated execution workspace.
</div>
</div>
{onUpdate ? (
<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={() => 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 rounded-md border border-border/70 px-3 py-2">
<div className="space-y-0.5">
<div className="text-sm">New issues default to isolated checkout</div>
<div className="text-[11px] text-muted-foreground">
If disabled, new issues stay on the projects 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" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
updateExecutionWorkspacePolicy({
defaultMode: executionWorkspaceDefaultMode === "isolated" ? "project_primary" : "isolated",
})}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated" ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
<CollapsibleSection
title="Advanced Checkout Settings"
open={executionWorkspaceAdvancedOpen}
onToggle={() => setExecutionWorkspaceAdvancedOpen((open) => !open)}
>
<div className="space-y-3 pt-1">
<div className="rounded-md border border-border/70 px-3 py-2 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="text-xs text-muted-foreground">Base ref</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.baseRef ?? ""}
onCommit={(value) =>
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="text-xs text-muted-foreground">Branch template</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.branchTemplate ?? ""}
onCommit={(value) =>
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="text-xs text-muted-foreground">Worktree parent dir</label>
</div>
<DraftInput
value={executionWorkspaceStrategy.worktreeParentDir ?? ""}
onCommit={(value) =>
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>
<p className="text-[11px] text-muted-foreground">
Runtime services stay under Paperclip control and are not configured here yet.
</p>
</div>
</CollapsibleSection>
</>
)}
</div>
</div>
<Separator />
<div className="py-1.5 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Workspaces</span>