Add project-first execution workspace policies
This commit is contained in:
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal file
2
packages/db/src/migrations/0027_tranquil_tenebrous.sql
Normal 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;
|
||||||
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
6205
packages/db/src/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
|||||||
"when": 1773089625430,
|
"when": 1773089625430,
|
||||||
"tag": "0026_lying_pete_wisdom",
|
"tag": "0026_lying_pete_wisdom",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773150731736,
|
||||||
|
"tag": "0027_tranquil_tenebrous",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,7 @@ export const issues = pgTable(
|
|||||||
requestDepth: integer("request_depth").notNull().default(0),
|
requestDepth: integer("request_depth").notNull().default(0),
|
||||||
billingCode: text("billing_code"),
|
billingCode: text("billing_code"),
|
||||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||||
|
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
||||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||||
|
|||||||
@@ -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 { companies } from "./companies.js";
|
||||||
import { goals } from "./goals.js";
|
import { goals } from "./goals.js";
|
||||||
import { agents } from "./agents.js";
|
import { agents } from "./agents.js";
|
||||||
@@ -15,6 +15,7 @@ export const projects = pgTable(
|
|||||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||||
targetDate: date("target_date"),
|
targetDate: date("target_date"),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
|
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
||||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export type {
|
|||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
|
ExecutionWorkspaceStrategyType,
|
||||||
|
ExecutionWorkspaceMode,
|
||||||
|
ExecutionWorkspaceStrategy,
|
||||||
|
ProjectExecutionWorkspacePolicy,
|
||||||
|
IssueExecutionWorkspaceSettings,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
@@ -157,9 +162,11 @@ export {
|
|||||||
type UpdateProject,
|
type UpdateProject,
|
||||||
type CreateProjectWorkspace,
|
type CreateProjectWorkspace,
|
||||||
type UpdateProjectWorkspace,
|
type UpdateProjectWorkspace,
|
||||||
|
projectExecutionWorkspacePolicySchema,
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
|
issueExecutionWorkspaceSettingsSchema,
|
||||||
checkoutIssueSchema,
|
checkoutIssueSchema,
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ export type {
|
|||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
export type { AssetImage } from "./asset.js";
|
export type { AssetImage } from "./asset.js";
|
||||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.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 {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||||
import type { Goal } from "./goal.js";
|
import type { Goal } from "./goal.js";
|
||||||
import type { Project, ProjectWorkspace } from "./project.js";
|
import type { Project, ProjectWorkspace } from "./project.js";
|
||||||
|
import type { IssueExecutionWorkspaceSettings } from "./workspace-runtime.js";
|
||||||
|
|
||||||
export interface IssueAncestorProject {
|
export interface IssueAncestorProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,6 +74,7 @@ export interface Issue {
|
|||||||
requestDepth: number;
|
requestDepth: number;
|
||||||
billingCode: string | null;
|
billingCode: string | null;
|
||||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||||
|
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||||
startedAt: Date | null;
|
startedAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
cancelledAt: Date | null;
|
cancelledAt: Date | null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ProjectStatus } from "../constants.js";
|
import type { ProjectStatus } from "../constants.js";
|
||||||
import type { WorkspaceRuntimeService } from "./workspace-runtime.js";
|
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||||
|
|
||||||
export interface ProjectGoalRef {
|
export interface ProjectGoalRef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +35,7 @@ export interface Project {
|
|||||||
leadAgentId: string | null;
|
leadAgentId: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
|
|||||||
@@ -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 {
|
export interface WorkspaceRuntimeService {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|||||||
@@ -49,16 +49,19 @@ export {
|
|||||||
updateProjectSchema,
|
updateProjectSchema,
|
||||||
createProjectWorkspaceSchema,
|
createProjectWorkspaceSchema,
|
||||||
updateProjectWorkspaceSchema,
|
updateProjectWorkspaceSchema,
|
||||||
|
projectExecutionWorkspacePolicySchema,
|
||||||
type CreateProject,
|
type CreateProject,
|
||||||
type UpdateProject,
|
type UpdateProject,
|
||||||
type CreateProjectWorkspace,
|
type CreateProjectWorkspace,
|
||||||
type UpdateProjectWorkspace,
|
type UpdateProjectWorkspace,
|
||||||
|
type ProjectExecutionWorkspacePolicy,
|
||||||
} from "./project.js";
|
} from "./project.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
|
issueExecutionWorkspaceSettingsSchema,
|
||||||
checkoutIssueSchema,
|
checkoutIssueSchema,
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
@@ -66,6 +69,7 @@ export {
|
|||||||
type CreateIssue,
|
type CreateIssue,
|
||||||
type CreateIssueLabel,
|
type CreateIssueLabel,
|
||||||
type UpdateIssue,
|
type UpdateIssue,
|
||||||
|
type IssueExecutionWorkspaceSettings,
|
||||||
type CheckoutIssue,
|
type CheckoutIssue,
|
||||||
type AddIssueComment,
|
type AddIssueComment,
|
||||||
type LinkIssueApproval,
|
type LinkIssueApproval,
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
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
|
export const issueAssigneeAdapterOverridesSchema = z
|
||||||
.object({
|
.object({
|
||||||
adapterConfig: z.record(z.unknown()).optional(),
|
adapterConfig: z.record(z.unknown()).optional(),
|
||||||
@@ -21,6 +38,7 @@ export const createIssueSchema = z.object({
|
|||||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||||
billingCode: z.string().optional().nullable(),
|
billingCode: z.string().optional().nullable(),
|
||||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||||
|
executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(),
|
||||||
labelIds: z.array(z.string().uuid()).optional(),
|
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 UpdateIssue = z.infer<typeof updateIssueSchema>;
|
||||||
|
export type IssueExecutionWorkspaceSettings = z.infer<typeof issueExecutionWorkspaceSettingsSchema>;
|
||||||
|
|
||||||
export const checkoutIssueSchema = z.object({
|
export const checkoutIssueSchema = z.object({
|
||||||
agentId: z.string().uuid(),
|
agentId: z.string().uuid(),
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PROJECT_STATUSES } from "../constants.js";
|
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 = {
|
const projectWorkspaceFields = {
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
cwd: z.string().min(1).optional().nullable(),
|
cwd: z.string().min(1).optional().nullable(),
|
||||||
@@ -43,6 +65,7 @@ const projectFields = {
|
|||||||
leadAgentId: z.string().uuid().optional().nullable(),
|
leadAgentId: z.string().uuid().optional().nullable(),
|
||||||
targetDate: z.string().optional().nullable(),
|
targetDate: z.string().optional().nullable(),
|
||||||
color: z.string().optional().nullable(),
|
color: z.string().optional().nullable(),
|
||||||
|
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||||
archivedAt: z.string().datetime().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 const updateProjectSchema = z.object(projectFields).partial();
|
||||||
|
|
||||||
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
export type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||||
|
|
||||||
|
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
|
||||||
|
|||||||
137
server/src/__tests__/execution-workspace-policy.test.ts
Normal file
137
server/src/__tests__/execution-workspace-policy.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
server/src/services/execution-workspace-policy.ts
Normal file
141
server/src/services/execution-workspace-policy.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
costEvents,
|
costEvents,
|
||||||
issues,
|
issues,
|
||||||
|
projects,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { conflict, notFound } from "../errors.js";
|
import { conflict, notFound } from "../errors.js";
|
||||||
@@ -31,6 +32,12 @@ import {
|
|||||||
releaseRuntimeServicesForRun,
|
releaseRuntimeServicesForRun,
|
||||||
} from "./workspace-runtime.js";
|
} from "./workspace-runtime.js";
|
||||||
import { issueService } from "./issues.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 MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||||
@@ -1080,8 +1087,10 @@ export function heartbeatService(db: Db) {
|
|||||||
const issueAssigneeConfig = issueId
|
const issueAssigneeConfig = issueId
|
||||||
? await db
|
? await db
|
||||||
.select({
|
.select({
|
||||||
|
projectId: issues.projectId,
|
||||||
assigneeAgentId: issues.assigneeAgentId,
|
assigneeAgentId: issues.assigneeAgentId,
|
||||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||||
|
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||||
})
|
})
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||||
@@ -1093,6 +1102,18 @@ export function heartbeatService(db: Db) {
|
|||||||
issueAssigneeConfig.assigneeAdapterOverrides,
|
issueAssigneeConfig.assigneeAdapterOverrides,
|
||||||
)
|
)
|
||||||
: null;
|
: 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
|
const taskSession = taskKey
|
||||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||||
: null;
|
: null;
|
||||||
@@ -1102,16 +1123,28 @@ export function heartbeatService(db: Db) {
|
|||||||
const previousSessionParams = normalizeSessionParams(
|
const previousSessionParams = normalizeSessionParams(
|
||||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
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(
|
const resolvedWorkspace = await resolveWorkspaceForRun(
|
||||||
agent,
|
agent,
|
||||||
context,
|
context,
|
||||||
previousSessionParams,
|
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
|
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||||
: config;
|
: workspaceManagedConfig;
|
||||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||||
agent.companyId,
|
agent.companyId,
|
||||||
mergedConfig,
|
mergedConfig,
|
||||||
@@ -1168,6 +1201,7 @@ export function heartbeatService(db: Db) {
|
|||||||
context.paperclipWorkspace = {
|
context.paperclipWorkspace = {
|
||||||
cwd: executionWorkspace.cwd,
|
cwd: executionWorkspace.cwd,
|
||||||
source: executionWorkspace.source,
|
source: executionWorkspace.source,
|
||||||
|
mode: executionWorkspaceMode,
|
||||||
strategy: executionWorkspace.strategy,
|
strategy: executionWorkspace.strategy,
|
||||||
projectId: executionWorkspace.projectId,
|
projectId: executionWorkspace.projectId,
|
||||||
workspaceId: executionWorkspace.workspaceId,
|
workspaceId: executionWorkspace.workspaceId,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { extractProjectMentionIds } from "@paperclipai/shared";
|
import { extractProjectMentionIds } from "@paperclipai/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
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"];
|
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");
|
throw unprocessable("in_progress issues require an assignee");
|
||||||
}
|
}
|
||||||
return db.transaction(async (tx) => {
|
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
|
const [company] = await tx
|
||||||
.update(companies)
|
.update(companies)
|
||||||
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
|
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
|
||||||
@@ -644,7 +661,13 @@ export function issueService(db: Db) {
|
|||||||
const issueNumber = company.issueCounter;
|
const issueNumber = company.issueCounter;
|
||||||
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
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) {
|
if (values.status === "in_progress" && !values.startedAt) {
|
||||||
values.startedAt = new Date();
|
values.startedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
deriveProjectUrlKey,
|
deriveProjectUrlKey,
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
normalizeProjectUrlKey,
|
normalizeProjectUrlKey,
|
||||||
|
type ProjectExecutionWorkspacePolicy,
|
||||||
type ProjectGoalRef,
|
type ProjectGoalRef,
|
||||||
type ProjectWorkspace,
|
type ProjectWorkspace,
|
||||||
type WorkspaceRuntimeService,
|
type WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||||
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
|
|
||||||
type ProjectRow = typeof projects.$inferSelect;
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||||
@@ -26,10 +28,11 @@ type CreateWorkspaceInput = {
|
|||||||
};
|
};
|
||||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||||
|
|
||||||
interface ProjectWithGoals extends ProjectRow {
|
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||||
urlKey: string;
|
urlKey: string;
|
||||||
goalIds: string[];
|
goalIds: string[];
|
||||||
goals: ProjectGoalRef[];
|
goals: ProjectGoalRef[];
|
||||||
|
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
}
|
}
|
||||||
@@ -77,6 +80,7 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
|||||||
urlKey: deriveProjectUrlKey(r.name, r.id),
|
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||||
goalIds: g.map((x) => x.id),
|
goalIds: g.map((x) => x.id),
|
||||||
goals: g,
|
goals: g,
|
||||||
|
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
|
||||||
} as ProjectWithGoals;
|
} as ProjectWithGoals;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { AdapterConfigFieldsProps } from "./types";
|
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";
|
import { RuntimeServicesJsonField } from "./runtime-json-fields";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
@@ -48,6 +49,7 @@ export function LocalWorkspaceRuntimeFields({
|
|||||||
config,
|
config,
|
||||||
mark,
|
mark,
|
||||||
}: AdapterConfigFieldsProps) {
|
}: AdapterConfigFieldsProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const existing = readWorkspaceStrategy(config);
|
const existing = readWorkspaceStrategy(config);
|
||||||
const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type;
|
const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type;
|
||||||
const updateEditWorkspaceStrategy = (patch: Partial<typeof existing>) => {
|
const updateEditWorkspaceStrategy = (patch: Partial<typeof existing>) => {
|
||||||
@@ -62,75 +64,81 @@ export function LocalWorkspaceRuntimeFields({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<CollapsibleSection
|
||||||
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
|
title="Advanced Workspace Overrides"
|
||||||
<select
|
open={open}
|
||||||
className={inputClass}
|
onToggle={() => setOpen((value) => !value)}
|
||||||
value={strategyType}
|
>
|
||||||
onChange={(e) => {
|
<div className="space-y-3 pt-1">
|
||||||
const nextType = e.target.value;
|
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
|
||||||
if (isCreate) {
|
<select
|
||||||
set!({ workspaceStrategyType: nextType });
|
className={inputClass}
|
||||||
} else {
|
value={strategyType}
|
||||||
updateEditWorkspaceStrategy({ type: nextType });
|
onChange={(e) => {
|
||||||
}
|
const nextType = e.target.value;
|
||||||
}}
|
if (isCreate) {
|
||||||
>
|
set!({ workspaceStrategyType: nextType });
|
||||||
<option value="project_primary">Project primary workspace</option>
|
} else {
|
||||||
<option value="git_worktree">Git worktree</option>
|
updateEditWorkspaceStrategy({ type: nextType });
|
||||||
</select>
|
}
|
||||||
</Field>
|
}}
|
||||||
|
>
|
||||||
|
<option value="project_primary">Project primary workspace</option>
|
||||||
|
<option value="git_worktree">Git worktree</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{strategyType === "git_worktree" && (
|
{strategyType === "git_worktree" && (
|
||||||
<>
|
<>
|
||||||
<Field label="Base ref" hint={help.workspaceBaseRef}>
|
<Field label="Base ref" hint={help.workspaceBaseRef}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
|
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
|
||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
isCreate
|
isCreate
|
||||||
? set!({ workspaceBaseRef: v })
|
? set!({ workspaceBaseRef: v })
|
||||||
: updateEditWorkspaceStrategy({ baseRef: v || "" })
|
: updateEditWorkspaceStrategy({ baseRef: v || "" })
|
||||||
}
|
}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="origin/main"
|
placeholder="origin/main"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
|
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
|
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
|
||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
isCreate
|
isCreate
|
||||||
? set!({ workspaceBranchTemplate: v })
|
? set!({ workspaceBranchTemplate: v })
|
||||||
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
|
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
|
||||||
}
|
}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="{{issue.identifier}}-{{slug}}"
|
placeholder="{{issue.identifier}}-{{slug}}"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
|
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
|
||||||
<DraftInput
|
<DraftInput
|
||||||
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
|
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
|
||||||
onCommit={(v) =>
|
onCommit={(v) =>
|
||||||
isCreate
|
isCreate
|
||||||
? set!({ worktreeParentDir: v })
|
? set!({ worktreeParentDir: v })
|
||||||
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
|
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
|
||||||
}
|
}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder=".paperclip/worktrees"
|
placeholder=".paperclip/worktrees"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RuntimeServicesJsonField
|
<RuntimeServicesJsonField
|
||||||
isCreate={isCreate}
|
isCreate={isCreate}
|
||||||
values={values}
|
values={values}
|
||||||
set={set}
|
set={set}
|
||||||
config={config}
|
config={config}
|
||||||
mark={mark}
|
mark={mark}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
const project = orderedProjects.find((p) => p.id === id);
|
const project = orderedProjects.find((p) => p.id === id);
|
||||||
return project?.name ?? id.slice(0, 8);
|
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) => {
|
const projectLink = (id: string | null) => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const project = projects?.find((p) => p.id === id) ?? 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",
|
"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"
|
!issue.projectId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
onClick={() => {
|
||||||
|
onUpdate({ projectId: null, executionWorkspaceSettings: null });
|
||||||
|
setProjectOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
No project
|
No project
|
||||||
</button>
|
</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",
|
"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"
|
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
|
<span
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
@@ -504,6 +525,42 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
{projectContent}
|
{projectContent}
|
||||||
</PropertyPicker>
|
</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 && (
|
{issue.parentId && (
|
||||||
<PropertyRow label="Parent">
|
<PropertyRow label="Parent">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ interface IssueDraft {
|
|||||||
assigneeModelOverride: string;
|
assigneeModelOverride: string;
|
||||||
assigneeThinkingEffort: string;
|
assigneeThinkingEffort: string;
|
||||||
assigneeChrome: boolean;
|
assigneeChrome: boolean;
|
||||||
assigneeUseProjectWorkspace: boolean;
|
useIsolatedExecutionWorkspace: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||||
@@ -99,7 +99,6 @@ function buildAssigneeAdapterOverrides(input: {
|
|||||||
modelOverride: string;
|
modelOverride: string;
|
||||||
thinkingEffortOverride: string;
|
thinkingEffortOverride: string;
|
||||||
chrome: boolean;
|
chrome: boolean;
|
||||||
useProjectWorkspace: boolean;
|
|
||||||
}): Record<string, unknown> | null {
|
}): Record<string, unknown> | null {
|
||||||
const adapterType = input.adapterType ?? null;
|
const adapterType = input.adapterType ?? null;
|
||||||
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
||||||
@@ -127,9 +126,6 @@ function buildAssigneeAdapterOverrides(input: {
|
|||||||
if (Object.keys(adapterConfig).length > 0) {
|
if (Object.keys(adapterConfig).length > 0) {
|
||||||
overrides.adapterConfig = adapterConfig;
|
overrides.adapterConfig = adapterConfig;
|
||||||
}
|
}
|
||||||
if (!input.useProjectWorkspace) {
|
|
||||||
overrides.useProjectWorkspace = false;
|
|
||||||
}
|
|
||||||
return Object.keys(overrides).length > 0 ? overrides : null;
|
return Object.keys(overrides).length > 0 ? overrides : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +176,11 @@ export function NewIssueDialog() {
|
|||||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||||
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
const [assigneeChrome, setAssigneeChrome] = useState(false);
|
||||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
|
||||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
|
||||||
|
|
||||||
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId;
|
||||||
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany;
|
||||||
@@ -300,7 +297,7 @@ export function NewIssueDialog() {
|
|||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
assigneeThinkingEffort,
|
assigneeThinkingEffort,
|
||||||
assigneeChrome,
|
assigneeChrome,
|
||||||
assigneeUseProjectWorkspace,
|
useIsolatedExecutionWorkspace,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
title,
|
title,
|
||||||
@@ -312,7 +309,7 @@ export function NewIssueDialog() {
|
|||||||
assigneeModelOverride,
|
assigneeModelOverride,
|
||||||
assigneeThinkingEffort,
|
assigneeThinkingEffort,
|
||||||
assigneeChrome,
|
assigneeChrome,
|
||||||
assigneeUseProjectWorkspace,
|
useIsolatedExecutionWorkspace,
|
||||||
newIssueOpen,
|
newIssueOpen,
|
||||||
scheduleSave,
|
scheduleSave,
|
||||||
]);
|
]);
|
||||||
@@ -321,6 +318,7 @@ export function NewIssueDialog() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!newIssueOpen) return;
|
if (!newIssueOpen) return;
|
||||||
setDialogCompanyId(selectedCompanyId);
|
setDialogCompanyId(selectedCompanyId);
|
||||||
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
|
|
||||||
const draft = loadDraft();
|
const draft = loadDraft();
|
||||||
if (newIssueDefaults.title) {
|
if (newIssueDefaults.title) {
|
||||||
@@ -333,7 +331,7 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setAssigneeUseProjectWorkspace(true);
|
setUseIsolatedExecutionWorkspace(false);
|
||||||
} else if (draft && draft.title.trim()) {
|
} else if (draft && draft.title.trim()) {
|
||||||
setTitle(draft.title);
|
setTitle(draft.title);
|
||||||
setDescription(draft.description);
|
setDescription(draft.description);
|
||||||
@@ -344,7 +342,7 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
|
||||||
} else {
|
} else {
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
@@ -353,7 +351,7 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setAssigneeUseProjectWorkspace(true);
|
setUseIsolatedExecutionWorkspace(false);
|
||||||
}
|
}
|
||||||
}, [newIssueOpen, newIssueDefaults]);
|
}, [newIssueOpen, newIssueDefaults]);
|
||||||
|
|
||||||
@@ -363,7 +361,6 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setAssigneeUseProjectWorkspace(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,10 +393,11 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setAssigneeUseProjectWorkspace(true);
|
setUseIsolatedExecutionWorkspace(false);
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
setDialogCompanyId(null);
|
setDialogCompanyId(null);
|
||||||
setCompanyOpen(false);
|
setCompanyOpen(false);
|
||||||
|
executionWorkspaceDefaultProjectId.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCompanyChange(companyId: string) {
|
function handleCompanyChange(companyId: string) {
|
||||||
@@ -410,7 +408,7 @@ export function NewIssueDialog() {
|
|||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setAssigneeUseProjectWorkspace(true);
|
setUseIsolatedExecutionWorkspace(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardDraft() {
|
function discardDraft() {
|
||||||
@@ -426,8 +424,14 @@ export function NewIssueDialog() {
|
|||||||
modelOverride: assigneeModelOverride,
|
modelOverride: assigneeModelOverride,
|
||||||
thinkingEffortOverride: assigneeThinkingEffort,
|
thinkingEffortOverride: assigneeThinkingEffort,
|
||||||
chrome: assigneeChrome,
|
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({
|
createIssue.mutate({
|
||||||
companyId: effectiveCompanyId,
|
companyId: effectiveCompanyId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
@@ -437,6 +441,7 @@ export function NewIssueDialog() {
|
|||||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||||
...(projectId ? { projectId } : {}),
|
...(projectId ? { projectId } : {}),
|
||||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||||
|
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +472,8 @@ export function NewIssueDialog() {
|
|||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||||
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||||
|
const currentProjectExecutionWorkspacePolicy = currentProject?.executionWorkspacePolicy ?? null;
|
||||||
|
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||||
const assigneeOptionsTitle =
|
const assigneeOptionsTitle =
|
||||||
assigneeAdapterType === "claude_local"
|
assigneeAdapterType === "claude_local"
|
||||||
? "Claude options"
|
? "Claude options"
|
||||||
@@ -503,6 +510,26 @@ export function NewIssueDialog() {
|
|||||||
})),
|
})),
|
||||||
[orderedProjects],
|
[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[]>(
|
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||||
() => {
|
() => {
|
||||||
return [...(assigneeAdapterModels ?? [])]
|
return [...(assigneeAdapterModels ?? [])]
|
||||||
@@ -705,7 +732,7 @@ export function NewIssueDialog() {
|
|||||||
noneLabel="No project"
|
noneLabel="No project"
|
||||||
searchPlaceholder="Search projects..."
|
searchPlaceholder="Search projects..."
|
||||||
emptyMessage="No projects found."
|
emptyMessage="No projects found."
|
||||||
onChange={setProjectId}
|
onChange={handleProjectChange}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
descriptionEditorRef.current?.focus();
|
descriptionEditorRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
@@ -740,6 +767,34 @@ export function NewIssueDialog() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{supportsAssigneeOverrides && (
|
||||||
<div className="px-4 pb-2 shrink-0">
|
<div className="px-4 pb-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -800,23 +855,6 @@ export function NewIssueDialog() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react";
|
import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
|
import { CollapsibleSection, DraftInput } from "./agent-config-primitives";
|
||||||
|
|
||||||
const PROJECT_STATUSES = [
|
const PROJECT_STATUSES = [
|
||||||
{ value: "backlog", label: "Backlog" },
|
{ value: "backlog", label: "Backlog" },
|
||||||
@@ -80,6 +81,7 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
|||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [goalOpen, setGoalOpen] = useState(false);
|
const [goalOpen, setGoalOpen] = useState(false);
|
||||||
|
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
|
||||||
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
|
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
|
||||||
const [workspaceCwd, setWorkspaceCwd] = useState("");
|
const [workspaceCwd, setWorkspaceCwd] = useState("");
|
||||||
const [workspaceRepoUrl, setWorkspaceRepoUrl] = 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 availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
|
||||||
const workspaces = project.workspaces ?? [];
|
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 = () => {
|
const invalidateProject = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||||
@@ -146,6 +158,19 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
|||||||
setGoalOpen(false);
|
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 isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
|
||||||
|
|
||||||
const isGitHubRepoUrl = (value: string) => {
|
const isGitHubRepoUrl = (value: string) => {
|
||||||
@@ -346,6 +371,162 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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 project’s 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 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" ? "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="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>Workspaces</span>
|
<span>Workspaces</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user