From caa7550e9fdd2f997197adda82b171cced222fa7 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 19:19:49 -0500 Subject: [PATCH] Fix shared workspace close semantics Allow shared execution workspace sessions to be archived with warnings instead of hard-blocking on open linked issues, clear issue workspace links when those shared sessions are archived, and update the close dialog copy and coverage. Co-Authored-By: Paperclip --- .../execution-workspaces-service.test.ts | 13 +++++++------ server/src/routes/execution-workspaces.ts | 17 ++++++++++++++++- server/src/services/execution-workspaces.ts | 12 ++++++++---- .../ExecutionWorkspaceCloseDialog.tsx | 2 +- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index c031ad58..d4a50bdc 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -148,7 +148,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { await tempDb?.cleanup(); }); - it("blocks close for shared workspaces that still have open linked issues", async () => { + it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -209,14 +209,15 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(readiness).toMatchObject({ workspaceId: executionWorkspaceId, - state: "blocked", + state: "ready_with_warnings", isSharedWorkspace: true, isProjectPrimaryWorkspace: true, - isDestructiveCloseAllowed: false, + isDestructiveCloseAllowed: true, }); - expect(readiness?.blockingReasons).toEqual(expect.arrayContaining([ - "This workspace is still linked to an open issue.", - "Shared execution workspaces are project infrastructure and cannot be destructively closed.", + expect(readiness?.blockingReasons).toEqual([]); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.", + "This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.", ])); }); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index d4291967..4fa20425 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { projects, projectWorkspaces } from "@paperclipai/db"; +import { issues, projects, projectWorkspaces } from "@paperclipai/db"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; @@ -303,6 +303,21 @@ export function executionWorkspaceRoutes(db: Db) { } workspace = archivedWorkspace; + if (existing.mode === "shared_workspace") { + await db + .update(issues) + .set({ + executionWorkspaceId: null, + updatedAt: new Date(), + }) + .where( + and( + eq(issues.companyId, existing.companyId), + eq(issues.executionWorkspaceId, existing.id), + ), + ); + } + try { await stopRuntimeServicesForExecutionWorkspace({ db, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 8b0cebde..a1d3b41d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -456,15 +456,19 @@ export function executionWorkspaceService(db: Db) { const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal); if (blockingIssues.length > 0) { - blockingReasons.push( + const linkedIssueMessage = blockingIssues.length === 1 ? "This workspace is still linked to an open issue." - : `This workspace is still linked to ${blockingIssues.length} open issues.`, - ); + : `This workspace is still linked to ${blockingIssues.length} open issues.`; + if (isSharedWorkspace) { + warnings.push(`${linkedIssueMessage} Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.`); + } else { + blockingReasons.push(linkedIssueMessage); + } } if (isSharedWorkspace) { - blockingReasons.push("Shared execution workspaces are project infrastructure and cannot be destructively closed."); + warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record."); } if (runtimeServices.some((service) => service.status !== "stopped")) { diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 4ad9a5bb..3b6aef8a 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -118,7 +118,7 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.isSharedWorkspace - ? "This is the shared project workspace session, so destructive close is blocked." + ? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace." : readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot ? "This execution workspace has its own checkout path and can be archived independently." : readiness.isProjectPrimaryWorkspace