diff --git a/packages/shared/src/execution-workspace-guards.ts b/packages/shared/src/execution-workspace-guards.ts new file mode 100644 index 00000000..5428546a --- /dev/null +++ b/packages/shared/src/execution-workspace-guards.ts @@ -0,0 +1,19 @@ +import type { ExecutionWorkspace } from "./types/workspace-runtime.js"; + +type ExecutionWorkspaceGuardTarget = Pick; + +const CLOSED_EXECUTION_WORKSPACE_STATUSES = new Set(["archived", "cleanup_failed"]); + +export function isClosedIsolatedExecutionWorkspace( + workspace: Pick | null | undefined, +): boolean { + if (!workspace) return false; + if (workspace.mode !== "isolated_workspace") return false; + return workspace.closedAt != null || CLOSED_EXECUTION_WORKSPACE_STATUSES.has(workspace.status); +} + +export function getClosedIsolatedExecutionWorkspaceMessage( + workspace: Pick, +): string { + return `This issue is linked to the closed workspace "${workspace.name}". Move it to an open workspace before adding comments or resuming work.`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b0fd87f2..8f35bf12 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -351,6 +351,11 @@ export { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, } from "./types/feedback.js"; +export { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, +} from "./execution-workspace-guards.js"; + export { instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts new file mode 100644 index 00000000..f7f0240c --- /dev/null +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -0,0 +1,178 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const issueId = "11111111-1111-4111-8111-111111111111"; +const closedWorkspaceId = "33333333-3333-4333-8333-333333333333"; +const nextWorkspaceId = "44444444-4444-4444-8444-444444444444"; +const agentId = "22222222-2222-4222-8222-222222222222"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + checkout: vi.fn(), + addComment: vi.fn(), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); + +const mockProjectService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({ + getDefaultCompanyGoal: vi.fn(async () => null), + getById: vi.fn(async () => null), + }), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue() { + return { + id: issueId, + companyId: "company-1", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-1085", + title: "Closed worktree issue", + projectId: null, + executionRunId: null, + checkoutRunId: null, + executionWorkspaceId: closedWorkspaceId, + }; +} + +function makeClosedWorkspace() { + return { + id: closedWorkspaceId, + name: "PAP-1085-fix-worktree-guard", + mode: "isolated_workspace", + status: "archived", + closedAt: new Date("2026-04-04T17:00:00.000Z"), + }; +} + +describe("closed isolated workspace issue routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace()); + }); + + it("rejects new issue comments when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .post(`/api/issues/${issueId}/comments`) + .send({ body: "hello" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + + it("rejects comment updates when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .patch(`/api/issues/${issueId}`) + .send({ comment: "hello" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + + it("rejects checkout when the linked isolated workspace is closed", async () => { + const res = await request(createApp()) + .post(`/api/issues/${issueId}/checkout`) + .send({ + agentId, + expectedStatuses: ["todo", "backlog", "blocked"], + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("closed workspace"); + expect(mockIssueService.checkout).not.toHaveBeenCalled(); + }); + + it("still allows non-comment board updates so the issue can be moved to a new workspace", async () => { + mockIssueService.update.mockResolvedValue({ + ...makeIssue(), + executionWorkspaceId: nextWorkspaceId, + }); + + const res = await request(createApp()) + .patch(`/api/issues/${issueId}`) + .send({ executionWorkspaceId: nextWorkspaceId }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith(issueId, { executionWorkspaceId: nextWorkspaceId }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9551f04d..2898feae 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -19,6 +19,9 @@ import { updateIssueWorkProductSchema, upsertIssueDocumentSchema, updateIssueSchema, + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ExecutionWorkspace, } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; @@ -234,6 +237,23 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) { + if (!issue.executionWorkspaceId) return null; + const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId); + if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null; + return workspace; + } + + function respondClosedIssueExecutionWorkspace( + res: Response, + workspace: Pick, + ) { + res.status(409).json({ + error: getClosedIsolatedExecutionWorkspaceMessage(workspace), + executionWorkspace: workspace, + }); + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -1083,6 +1103,13 @@ export function issueRoutes( ...updateFields } = req.body; let interruptedRunId: string | null = null; + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing); + const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0; + + if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } if (interruptRequested) { if (!commentBody) { @@ -1389,6 +1416,12 @@ export function issueRoutes( return; } + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); + if (closedExecutionWorkspace) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } + const checkoutRunId = requireAgentRunId(req, res); if (req.actor.type === "agent" && !checkoutRunId) return; const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); @@ -1607,6 +1640,11 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); + if (closedExecutionWorkspace) { + respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); + return; + } const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 8ba65c60..aa972337 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -120,4 +120,28 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("replaces the composer with a warning when comments are disabled", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + expect(container.textContent).toContain("Workspace is closed."); + expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull(); + expect(container.textContent).not.toContain("Comment"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b0b5c618..79f58702 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -78,6 +78,7 @@ interface CommentThreadProps { mentions?: MentionOption[]; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; + composerDisabledReason?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -569,6 +570,7 @@ export function CommentThread({ mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, + composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -796,90 +798,96 @@ export function CommentThread({ )} -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - + {composerDisabledReason ? ( +
+ {composerDisabledReason}
-
+ ) : ( +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
+ )}
); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d1f16ab7..d16a3103 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -71,8 +71,16 @@ import { SlidersHorizontal, Trash2, } from "lucide-react"; -import type { ActivityEvent } from "@paperclipai/shared"; -import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; +import { + getClosedIsolatedExecutionWorkspaceMessage, + isClosedIsolatedExecutionWorkspace, + type ActivityEvent, + type Agent, + type FeedbackVote, + type Issue, + type IssueAttachment, + type IssueComment, +} from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { @@ -306,6 +314,12 @@ export function IssueDetail() { enabled: !!issueId, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; + const commentComposerDisabledReason = useMemo(() => { + if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { + return null; + } + return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); + }, [issue?.currentExecutionWorkspace]); const { data: comments } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), @@ -1522,6 +1536,7 @@ export function IssueDetail() { await interruptQueuedComment.mutateAsync(runId); }} interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} + composerDisabledReason={commentComposerDisabledReason} onVote={async (commentId, vote, options) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment",