From f1ad07616ce8c1231a2969dcf9431789f40d314b Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 16:15:20 -0500 Subject: [PATCH] Add execution workspace close readiness and UI Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 12 + packages/shared/src/types/index.ts | 6 + .../shared/src/types/workspace-runtime.ts | 58 +++ .../src/validators/execution-workspace.ts | 88 ++++ packages/shared/src/validators/index.ts | 6 + .../execution-workspaces-service.test.ts | 252 ++++++++++- server/src/routes/execution-workspaces.ts | 38 +- server/src/services/execution-workspaces.ts | 405 +++++++++++++++++- ui/src/api/execution-workspaces.ts | 4 +- .../ExecutionWorkspaceCloseDialog.tsx | 292 +++++++++++++ ui/src/lib/project-workspaces-tab.ts | 4 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/ExecutionWorkspaceDetail.tsx | 50 ++- ui/src/pages/ProjectDetail.tsx | 232 ++++++---- 14 files changed, 1342 insertions(+), 106 deletions(-) create mode 100644 ui/src/components/ExecutionWorkspaceCloseDialog.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d974d7f1..0c5e08b8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -187,6 +187,12 @@ export type { ProjectWorkspace, ExecutionWorkspace, ExecutionWorkspaceConfig, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseActionKind, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseLinkedIssue, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceCloseReadinessState, WorkspaceRuntimeService, WorkspaceOperation, WorkspaceOperationPhase, @@ -385,6 +391,12 @@ export { issueWorkProductReviewStateSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, + executionWorkspaceCloseActionKindSchema, + executionWorkspaceCloseActionSchema, + executionWorkspaceCloseGitReadinessSchema, + executionWorkspaceCloseLinkedIssueSchema, + executionWorkspaceCloseReadinessSchema, + executionWorkspaceCloseReadinessStateSchema, issueDocumentFormatSchema, issueDocumentKeySchema, upsertIssueDocumentSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b356999b..eaa61faf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -51,6 +51,12 @@ export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, P export type { ExecutionWorkspace, ExecutionWorkspaceConfig, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseActionKind, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseLinkedIssue, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceCloseReadinessState, WorkspaceRuntimeService, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index b5e22c05..6facf57e 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -31,6 +31,20 @@ export type ExecutionWorkspaceStatus = | "archived" | "cleanup_failed"; +export type ExecutionWorkspaceCloseReadinessState = + | "ready" + | "ready_with_warnings" + | "blocked"; + +export type ExecutionWorkspaceCloseActionKind = + | "archive_record" + | "stop_runtime_services" + | "cleanup_command" + | "teardown_command" + | "git_worktree_remove" + | "git_branch_delete" + | "remove_local_directory"; + export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; baseRef?: string | null; @@ -47,6 +61,50 @@ export interface ExecutionWorkspaceConfig { workspaceRuntime: Record | null; } +export interface ExecutionWorkspaceCloseAction { + kind: ExecutionWorkspaceCloseActionKind; + label: string; + description: string; + command: string | null; +} + +export interface ExecutionWorkspaceCloseLinkedIssue { + id: string; + identifier: string | null; + title: string; + status: string; + isTerminal: boolean; +} + +export interface ExecutionWorkspaceCloseGitReadiness { + repoRoot: string | null; + workspacePath: string | null; + branchName: string | null; + baseRef: string | null; + hasDirtyTrackedFiles: boolean; + hasUntrackedFiles: boolean; + dirtyEntryCount: number; + untrackedEntryCount: number; + aheadCount: number | null; + behindCount: number | null; + isMergedIntoBase: boolean | null; + createdByRuntime: boolean; +} + +export interface ExecutionWorkspaceCloseReadiness { + workspaceId: string; + state: ExecutionWorkspaceCloseReadinessState; + blockingReasons: string[]; + warnings: string[]; + linkedIssues: ExecutionWorkspaceCloseLinkedIssue[]; + plannedActions: ExecutionWorkspaceCloseAction[]; + isDestructiveCloseAllowed: boolean; + isSharedWorkspace: boolean; + isProjectPrimaryWorkspace: boolean; + git: ExecutionWorkspaceCloseGitReadiness | null; + runtimeServices: WorkspaceRuntimeService[]; +} + export interface ProjectExecutionWorkspacePolicy { enabled: boolean; defaultMode?: ProjectExecutionWorkspaceDefaultMode; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index d1d74b60..6a31ba3b 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -15,6 +15,94 @@ export const executionWorkspaceConfigSchema = z.object({ workspaceRuntime: z.record(z.unknown()).optional().nullable(), }).strict(); +export const executionWorkspaceCloseReadinessStateSchema = z.enum([ + "ready", + "ready_with_warnings", + "blocked", +]); + +export const executionWorkspaceCloseActionKindSchema = z.enum([ + "archive_record", + "stop_runtime_services", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + "remove_local_directory", +]); + +export const executionWorkspaceCloseActionSchema = z.object({ + kind: executionWorkspaceCloseActionKindSchema, + label: z.string(), + description: z.string(), + command: z.string().nullable(), +}).strict(); + +export const executionWorkspaceCloseLinkedIssueSchema = z.object({ + id: z.string().uuid(), + identifier: z.string().nullable(), + title: z.string(), + status: z.string(), + isTerminal: z.boolean(), +}).strict(); + +export const executionWorkspaceCloseGitReadinessSchema = z.object({ + repoRoot: z.string().nullable(), + workspacePath: z.string().nullable(), + branchName: z.string().nullable(), + baseRef: z.string().nullable(), + hasDirtyTrackedFiles: z.boolean(), + hasUntrackedFiles: z.boolean(), + dirtyEntryCount: z.number().int().nonnegative(), + untrackedEntryCount: z.number().int().nonnegative(), + aheadCount: z.number().int().nonnegative().nullable(), + behindCount: z.number().int().nonnegative().nullable(), + isMergedIntoBase: z.boolean().nullable(), + createdByRuntime: z.boolean(), +}).strict(); + +export const executionWorkspaceCloseReadinessSchema = z.object({ + workspaceId: z.string().uuid(), + state: executionWorkspaceCloseReadinessStateSchema, + blockingReasons: z.array(z.string()), + warnings: z.array(z.string()), + linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema), + plannedActions: z.array(executionWorkspaceCloseActionSchema), + isDestructiveCloseAllowed: z.boolean(), + isSharedWorkspace: z.boolean(), + isProjectPrimaryWorkspace: z.boolean(), + git: executionWorkspaceCloseGitReadinessSchema.nullable(), + runtimeServices: z.array(z.object({ + id: z.string(), + companyId: z.string().uuid(), + projectId: z.string().uuid().nullable(), + projectWorkspaceId: z.string().uuid().nullable(), + executionWorkspaceId: z.string().uuid().nullable(), + issueId: z.string().uuid().nullable(), + scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]), + scopeId: z.string().nullable(), + serviceName: z.string(), + status: z.enum(["starting", "running", "stopped", "failed"]), + lifecycle: z.enum(["shared", "ephemeral"]), + reuseKey: z.string().nullable(), + command: z.string().nullable(), + cwd: z.string().nullable(), + port: z.number().int().nullable(), + url: z.string().nullable(), + provider: z.enum(["local_process", "adapter_managed"]), + providerRef: z.string().nullable(), + ownerAgentId: z.string().uuid().nullable(), + startedByRunId: z.string().uuid().nullable(), + lastUsedAt: z.coerce.date(), + startedAt: z.coerce.date(), + stoppedAt: z.coerce.date().nullable(), + stopPolicy: z.record(z.unknown()).nullable(), + healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + }).strict()), +}).strict(); + export const updateExecutionWorkspaceSchema = z.object({ name: z.string().min(1).optional(), cwd: z.string().optional().nullable(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 094a5bd3..34455deb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -154,6 +154,12 @@ export { executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, + executionWorkspaceCloseActionKindSchema, + executionWorkspaceCloseActionSchema, + executionWorkspaceCloseGitReadinessSchema, + executionWorkspaceCloseLinkedIssueSchema, + executionWorkspaceCloseReadinessSchema, + executionWorkspaceCloseReadinessStateSchema, type UpdateExecutionWorkspace, } from "./execution-workspace.js"; diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 86ecc104..57e86a42 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -1,9 +1,30 @@ -import { describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + executionWorkspaceService, mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig, } from "../services/execution-workspaces.ts"; +const execFileAsync = promisify(execFile); + describe("execution workspace config helpers", () => { it("reads typed config from persisted metadata", () => { expect(readExecutionWorkspaceConfig({ @@ -70,3 +91,232 @@ describe("execution workspace config helpers", () => { }); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createTempRepo() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-")); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["branch", "-M", "main"]); + return repoRoot; +} + +describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const tempDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-"); + db = createDb(tempDb.connectionString); + svc = executionWorkspaceService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(companies); + + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("blocks close for shared workspaces that still have open linked issues", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd: "/tmp/paperclip-primary", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-primary", + metadata: { + config: { + teardownCommand: "bash ./scripts/teardown.sh", + }, + }, + }); + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + title: "Still working", + status: "todo", + priority: "medium", + executionWorkspaceId, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "blocked", + isSharedWorkspace: true, + isProjectPrimaryWorkspace: true, + isDestructiveCloseAllowed: false, + }); + 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.", + ])); + }); + + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { + const repoRoot = await createTempRepo(); + tempDirs.add(repoRoot); + const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`); + tempDirs.add(worktreePath); + + await runGit(repoRoot, ["branch", "paperclip-close-check"]); + await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]); + await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8"); + await runGit(worktreePath, ["add", "feature.txt"]); + await runGit(worktreePath, ["commit", "-m", "Feature commit"]); + await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8"); + + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "bash ./scripts/project-teardown.sh", + }, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "git_repo", + isPrimary: true, + cwd: repoRoot, + cleanupCommand: "printf 'project cleanup\\n'", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + metadata: { + createdByRuntime: true, + config: { + cleanupCommand: "printf 'workspace cleanup\\n'", + }, + }, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "ready_with_warnings", + isSharedWorkspace: false, + isProjectPrimaryWorkspace: true, + isDestructiveCloseAllowed: true, + git: { + workspacePath: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + createdByRuntime: true, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: true, + aheadCount: 1, + behindCount: 0, + isMergedIntoBase: false, + }, + }); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "The workspace has 1 untracked file.", + "This workspace is 1 commit ahead of main and is not merged.", + ])); + expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([ + "archive_record", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + ])); + }, 20_000); +}); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 1e52263d..697f3413 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 { issues, projects, projectWorkspaces } from "@paperclipai/db"; +import { projects, projectWorkspaces } from "@paperclipai/db"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; @@ -13,8 +13,6 @@ import { } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); - export function executionWorkspaceRoutes(db: Db) { const router = Router(); const svc = executionWorkspaceService(db); @@ -44,6 +42,22 @@ export function executionWorkspaceRoutes(db: Db) { res.json(workspace); }); + router.get("/execution-workspaces/:id/close-readiness", async (req, res) => { + const id = req.params.id as string; + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, workspace.companyId); + const readiness = await svc.getCloseReadiness(id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + res.json(readiness); + }); + router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -80,18 +94,16 @@ export function executionWorkspaceRoutes(db: Db) { ); if (req.body.status === "archived" && existing.status !== "archived") { - const linkedIssues = await db - .select({ - id: issues.id, - status: issues.status, - }) - .from(issues) - .where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id))); - const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status)); + const readiness = await svc.getCloseReadiness(existing.id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } - if (activeLinkedIssues.length > 0) { + if (readiness.state === "blocked") { res.status(409).json({ - error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`, + error: readiness.blockingReasons[0] ?? "Execution workspace cannot be closed right now", + closeReadiness: readiness, }); return; } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index fa099a33..ffa94f9d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,10 +1,24 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; -import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared"; +import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import type { + ExecutionWorkspace, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceConfig, + WorkspaceRuntimeService, +} from "@paperclipai/shared"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; +const execFileAsync = promisify(execFile); +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -21,6 +35,149 @@ function cloneRecord(value: unknown): Record | null { return { ...value }; } +async function pathExists(value: string | null | undefined) { + if (!value) return false; + try { + await fs.access(value); + return true; + } catch { + return false; + } +} + +async function runGit(args: string[], cwd: string) { + return await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{ + git: ExecutionWorkspaceCloseGitReadiness | null; + warnings: string[]; +}> { + const warnings: string[] = []; + const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd); + const createdByRuntime = workspace.metadata?.createdByRuntime === true; + const expectsGitInspection = + workspace.providerType === "git_worktree" || + Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath); + + if (!expectsGitInspection) { + return { git: null, warnings }; + } + + if (!workspacePath) { + warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close."); + return { git: null, warnings }; + } + + if (!(await pathExists(workspacePath))) { + warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`); + return { + git: { + repoRoot: null, + workspacePath, + branchName: workspace.branchName, + baseRef: workspace.baseRef, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: false, + dirtyEntryCount: 0, + untrackedEntryCount: 0, + aheadCount: null, + behindCount: null, + isMergedIntoBase: null, + createdByRuntime, + }, + warnings, + }; + } + + let repoRoot: string | null = null; + try { + repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null; + } catch (error) { + warnings.push( + `Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let branchName = workspace.branchName; + if (repoRoot && !branchName) { + try { + branchName = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], workspacePath)).stdout.trim() || null; + } catch { + branchName = workspace.branchName; + } + } + + let dirtyEntryCount = 0; + let untrackedEntryCount = 0; + if (repoRoot) { + try { + const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout; + for (const line of statusOutput.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith("??")) { + untrackedEntryCount += 1; + continue; + } + dirtyEntryCount += 1; + } + } catch (error) { + warnings.push( + `Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + let aheadCount: number | null = null; + let behindCount: number | null = null; + let isMergedIntoBase: boolean | null = null; + const baseRef = workspace.baseRef; + + if (repoRoot && baseRef) { + try { + const counts = (await runGit(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], workspacePath)).stdout.trim(); + const [behindRaw, aheadRaw] = counts.split(/\s+/); + behindCount = behindRaw ? Number.parseInt(behindRaw, 10) : 0; + aheadCount = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0; + } catch (error) { + warnings.push( + `Could not compare this workspace against ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + await runGit(["merge-base", "--is-ancestor", "HEAD", baseRef], workspacePath); + isMergedIntoBase = true; + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : null; + if (code === 1) isMergedIntoBase = false; + else { + warnings.push( + `Could not determine whether this workspace is merged into ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + return { + git: { + repoRoot, + workspacePath, + branchName, + baseRef, + hasDirtyTrackedFiles: dirtyEntryCount > 0, + hasUntrackedFiles: untrackedEntryCount > 0, + dirtyEntryCount, + untrackedEntryCount, + aheadCount, + behindCount, + isMergedIntoBase, + createdByRuntime, + }, + warnings, + }; +} + export function readExecutionWorkspaceConfig(metadata: Record | null | undefined): ExecutionWorkspaceConfig | null { const raw = isRecord(metadata?.config) ? metadata.config : null; if (!raw) return null; @@ -198,6 +355,250 @@ export function executionWorkspaceService(db: Db) { return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); }, + getCloseReadiness: async (id: string): Promise => { + const workspace = await db + .select() + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, id)) + .then((rows) => rows[0] ?? null); + if (!workspace) return null; + + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + const runtimeServices = runtimeServiceRows.map(toRuntimeService); + + const linkedIssues = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, workspace.companyId), eq(issues.executionWorkspaceId, workspace.id))); + + const projectWorkspace = workspace.projectWorkspaceId + ? await db + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + isPrimary: projectWorkspaces.isPrimary, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.id, workspace.projectWorkspaceId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const primaryProjectWorkspace = workspace.projectId + ? await db + .select({ + id: projectWorkspaces.id, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + eq(projectWorkspaces.isPrimary, true), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const projectPolicy = workspace.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; + + const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices); + const config = readExecutionWorkspaceConfig((workspace.metadata as Record | null) ?? null); + const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace); + const warnings = [...gitWarnings]; + const blockingReasons: string[] = []; + const isSharedWorkspace = executionWorkspace.mode === "shared_workspace"; + const isProjectPrimaryWorkspace = workspace.projectWorkspaceId != null && workspace.projectWorkspaceId === primaryProjectWorkspace?.id; + + const linkedIssueSummaries = linkedIssues.map((issue) => ({ + ...issue, + isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status), + })); + + const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal); + if (blockingIssues.length > 0) { + blockingReasons.push( + blockingIssues.length === 1 + ? "This workspace is still linked to an open issue." + : `This workspace is still linked to ${blockingIssues.length} open issues.`, + ); + } + + if (isSharedWorkspace) { + blockingReasons.push("Shared execution workspaces are project infrastructure and cannot be destructively closed."); + } + + if (runtimeServices.some((service) => service.status !== "stopped")) { + warnings.push( + runtimeServices.length === 1 + ? "Closing this workspace will stop 1 attached runtime service." + : `Closing this workspace will stop ${runtimeServices.length} attached runtime services.`, + ); + } + + if (git?.hasDirtyTrackedFiles) { + warnings.push( + git.dirtyEntryCount === 1 + ? "The workspace has 1 modified tracked file." + : `The workspace has ${git.dirtyEntryCount} modified tracked files.`, + ); + } + if (git?.hasUntrackedFiles) { + warnings.push( + git.untrackedEntryCount === 1 + ? "The workspace has 1 untracked file." + : `The workspace has ${git.untrackedEntryCount} untracked files.`, + ); + } + if (git?.aheadCount && git.aheadCount > 0 && git.isMergedIntoBase === false) { + warnings.push( + git.aheadCount === 1 + ? `This workspace is 1 commit ahead of ${git.baseRef ?? "the base ref"} and is not merged.` + : `This workspace is ${git.aheadCount} commits ahead of ${git.baseRef ?? "the base ref"} and is not merged.`, + ); + } + if (git?.behindCount && git.behindCount > 0) { + warnings.push( + git.behindCount === 1 + ? `This workspace is 1 commit behind ${git.baseRef ?? "the base ref"}.` + : `This workspace is ${git.behindCount} commits behind ${git.baseRef ?? "the base ref"}.`, + ); + } + + const plannedActions: ExecutionWorkspaceCloseAction[] = [ + { + kind: "archive_record", + label: "Archive workspace record", + description: "Keep the execution workspace history and issue linkage, but remove it from active workspace lists.", + command: null, + }, + ]; + + if (runtimeServices.some((service) => service.status !== "stopped")) { + plannedActions.push({ + kind: "stop_runtime_services", + label: runtimeServices.length === 1 ? "Stop attached runtime service" : "Stop attached runtime services", + description: + runtimeServices.length === 1 + ? `${runtimeServices[0]?.serviceName ?? "A runtime service"} will be stopped before cleanup.` + : `${runtimeServices.length} runtime services will be stopped before cleanup.`, + command: null, + }); + } + + const configuredCleanupCommands = [ + { + kind: "cleanup_command" as const, + label: "Run workspace cleanup command", + description: "Workspace-specific cleanup runs before teardown.", + command: config?.cleanupCommand ?? null, + }, + { + kind: "cleanup_command" as const, + label: "Run project workspace cleanup command", + description: "Project workspace cleanup runs before execution workspace teardown.", + command: projectWorkspace?.cleanupCommand ?? null, + }, + ]; + for (const action of configuredCleanupCommands) { + if (!action.command) continue; + plannedActions.push(action); + } + + const teardownCommand = config?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null; + if (teardownCommand) { + plannedActions.push({ + kind: "teardown_command", + label: "Run teardown command", + description: "Teardown runs after cleanup commands during workspace close.", + command: teardownCommand, + }); + } + + const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd); + if (executionWorkspace.providerType === "git_worktree" && workspacePath) { + plannedActions.push({ + kind: "git_worktree_remove", + label: "Remove git worktree", + description: `Paperclip will run git worktree cleanup for ${workspacePath}.`, + command: `git worktree remove --force ${workspacePath}`, + }); + } + + if (git?.createdByRuntime && executionWorkspace.branchName) { + plannedActions.push({ + kind: "git_branch_delete", + label: "Delete runtime-created branch", + description: "Paperclip will try to delete the runtime-created branch after removing the worktree.", + command: `git branch -d ${executionWorkspace.branchName}`, + }); + } + + if (executionWorkspace.providerType === "local_fs" && git?.createdByRuntime && workspacePath) { + const resolvedWorkspacePath = path.resolve(workspacePath); + const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; + const containsProjectWorkspace = resolvedProjectWorkspacePath + ? ( + resolvedWorkspacePath === resolvedProjectWorkspacePath || + resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) + : false; + if (containsProjectWorkspace) { + warnings.push(`Paperclip will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`); + } else { + plannedActions.push({ + kind: "remove_local_directory", + label: "Remove runtime-created directory", + description: `Paperclip will remove the runtime-created directory at ${workspacePath}.`, + command: `rm -rf ${workspacePath}`, + }); + } + } + + const state = + blockingReasons.length > 0 + ? "blocked" + : warnings.length > 0 + ? "ready_with_warnings" + : "ready"; + + return { + workspaceId: workspace.id, + state, + blockingReasons, + warnings, + linkedIssues: linkedIssueSummaries, + plannedActions, + isDestructiveCloseAllowed: blockingReasons.length === 0, + isSharedWorkspace, + isProjectPrimaryWorkspace, + git, + runtimeServices, + }; + }, + create: async (data: typeof executionWorkspaces.$inferInsert) => { const row = await db .insert(executionWorkspaces) diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index bf83999c..59a447ca 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,4 +1,4 @@ -import type { ExecutionWorkspace } from "@paperclipai/shared"; +import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness } from "@paperclipai/shared"; import { api } from "./client"; export const executionWorkspacesApi = { @@ -22,5 +22,7 @@ export const executionWorkspacesApi = { return api.get(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`); }, get: (id: string) => api.get(`/execution-workspaces/${id}`), + getCloseReadiness: (id: string) => + api.get(`/execution-workspaces/${id}/close-readiness`), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx new file mode 100644 index 00000000..d44ba698 --- /dev/null +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -0,0 +1,292 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { Loader2 } from "lucide-react"; +import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { useToast } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { formatDateTime, issueUrl } from "../lib/utils"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +type ExecutionWorkspaceCloseDialogProps = { + workspaceId: string; + workspaceName: string; + currentStatus: ExecutionWorkspace["status"]; + open: boolean; + onOpenChange: (open: boolean) => void; + onClosed?: (workspace: ExecutionWorkspace) => void; +}; + +function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") { + if (state === "blocked") { + return "border-destructive/30 bg-destructive/5 text-destructive"; + } + if (state === "ready_with_warnings") { + return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300"; + } + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; +} + +export function ExecutionWorkspaceCloseDialog({ + workspaceId, + workspaceName, + currentStatus, + open, + onOpenChange, + onClosed, +}: ExecutionWorkspaceCloseDialogProps) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace"; + + const readinessQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId), + queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId), + enabled: open, + }); + + const closeWorkspace = useMutation({ + mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }), + onSuccess: (workspace) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) }); + pushToast({ + title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed", + tone: "success", + }); + onOpenChange(false); + onClosed?.(workspace); + }, + onError: (error) => { + pushToast({ + title: "Failed to close workspace", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + const readiness = readinessQuery.data ?? null; + const confirmDisabled = + currentStatus === "archived" || + closeWorkspace.isPending || + readinessQuery.isLoading || + readiness == null || + readiness.state === "blocked"; + + return ( + { + if (!closeWorkspace.isPending) onOpenChange(nextOpen); + }}> + + + {actionLabel} + + Archive {workspaceName} and clean up any owned workspace + artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. + + + + {readinessQuery.isLoading ? ( +
+ + Checking whether this workspace is safe to close... +
+ ) : readinessQuery.error ? ( +
+ {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} +
+ ) : readiness ? ( +
+
+
+ {readiness.state === "blocked" + ? "Close is blocked" + : readiness.state === "ready_with_warnings" + ? "Close is allowed with warnings" + : "Close is ready"} +
+
+ {readiness.isSharedWorkspace + ? "This workspace is attached to shared project infrastructure." + : readiness.isProjectPrimaryWorkspace + ? "This workspace is based on the project's primary workspace." + : "This workspace is disposable and can be archived."} +
+
+ + {readiness.blockingReasons.length > 0 ? ( +
+

Blocking reasons

+
    + {readiness.blockingReasons.map((reason) => ( +
  • + {reason} +
  • + ))} +
+
+ ) : null} + + {readiness.warnings.length > 0 ? ( +
+

Warnings

+
    + {readiness.warnings.map((warning) => ( +
  • + {warning} +
  • + ))} +
+
+ ) : null} + + {readiness.git ? ( +
+

Git status

+
+
+
+
Branch
+
{readiness.git.branchName ?? "Unknown"}
+
+
+
Base ref
+
{readiness.git.baseRef ?? "Not set"}
+
+
+
Merged into base
+
{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}
+
+
+
Ahead / behind
+
+ {(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()} +
+
+
+
Dirty tracked files
+
{readiness.git.dirtyEntryCount}
+
+
+
Untracked files
+
{readiness.git.untrackedEntryCount}
+
+
+
+
+ ) : null} + + {readiness.linkedIssues.length > 0 ? ( +
+

Linked issues

+
+ {readiness.linkedIssues.map((issue) => ( +
+
+ + {issue.identifier ?? issue.id} · {issue.title} + + {issue.status} +
+
+ ))} +
+
+ ) : null} + + {readiness.runtimeServices.length > 0 ? ( +
+

Attached runtime services

+
+ {readiness.runtimeServices.map((service) => ( +
+
+ {service.serviceName} + {service.status} · {service.lifecycle} +
+
+ {service.url ?? service.command ?? service.cwd ?? "No additional details"} +
+
+ ))} +
+
+ ) : null} + +
+

Cleanup actions

+
+ {readiness.plannedActions.map((action, index) => ( +
+
{action.label}
+
{action.description}
+ {action.command ? ( +
+                        {action.command}
+                      
+ ) : null} +
+ ))} +
+
+ + {currentStatus === "cleanup_failed" ? ( +
+ Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the + workspace status if it succeeds. +
+ ) : null} + + {currentStatus === "archived" ? ( +
+ This workspace is already archived. +
+ ) : null} + + {readiness.git?.repoRoot ? ( +
+ Repo root: {readiness.git.repoRoot} + {readiness.git.workspacePath ? ( + <> + {" · "}Workspace path: {readiness.git.workspacePath} + + ) : null} +
+ ) : null} + +
+ Last checked {formatDateTime(new Date())} +
+
+ ) : null} + + + + + +
+
+ ); +} diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index 1f5846c5..ca466461 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -12,6 +12,7 @@ export interface ProjectWorkspaceSummary { lastUpdatedAt: Date; projectWorkspaceId: string | null; executionWorkspaceId: string | null; + executionWorkspaceStatus: ExecutionWorkspace["status"] | null; issues: Issue[]; } @@ -65,6 +66,7 @@ export function buildProjectWorkspaceSummaries(input: { if (issue.executionWorkspaceId) { const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); if (!executionWorkspace) continue; + if (executionWorkspace.status === "archived") continue; if (isDefaultSharedExecutionWorkspace({ executionWorkspace, issue, @@ -91,6 +93,7 @@ export function buildProjectWorkspaceSummaries(input: { ), projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, executionWorkspaceId: executionWorkspace.id, + executionWorkspaceStatus: executionWorkspace.status, issues: nextIssues, }); continue; @@ -115,6 +118,7 @@ export function buildProjectWorkspaceSummaries(input: { lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), projectWorkspaceId: projectWorkspace.id, executionWorkspaceId: null, + executionWorkspaceStatus: null, issues: nextIssues, }); } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 8b7f2cd7..d54d6bf0 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -61,6 +61,7 @@ export const queryKeys = { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, + closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 484b7ac8..ec483db7 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { CopyText } from "../components/CopyText"; +import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; @@ -211,6 +212,7 @@ export function ExecutionWorkspaceDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const { selectedCompanyId, setSelectedCompanyId } = useCompany(); const [form, setForm] = useState(null); + const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const workspaceQuery = useQuery({ @@ -278,6 +280,7 @@ export function ExecutionWorkspaceDetail() { mutationFn: (patch: Record) => executionWorkspacesApi.update(workspace!.id, patch), onSuccess: (nextWorkspace) => { queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) }); if (project) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); @@ -322,8 +325,9 @@ export function ExecutionWorkspaceDetail() { }; return ( -
-
+ <> +
+
-
-
-
+
+
+
@@ -352,6 +356,15 @@ export function ExecutionWorkspaceDetail() { and runtime-service behavior in sync with the actual workspace being reused.

+
+ +
@@ -474,7 +487,7 @@ export function ExecutionWorkspaceDetail() {
-
+
Linked objects

Workspace context

@@ -519,7 +532,7 @@ export function ExecutionWorkspaceDetail() {
-
+
Paths and refs

Concrete location

@@ -563,7 +576,7 @@ export function ExecutionWorkspaceDetail() {
-
+
Runtime services

Attached services

@@ -597,8 +610,27 @@ export function ExecutionWorkspaceDetail() {

No runtime services are attached to this execution workspace.

)}
+
-
+ { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(nextWorkspace.id) }); + if (project) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(project.companyId, { projectId: project.id }) }); + } + if (sourceIssue) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) }); + } + }} + /> + ); } diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index f9e5cdc0..572bd3ac 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -20,12 +20,14 @@ import { CopyText } from "../components/CopyText"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; +import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; +import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; @@ -208,101 +210,166 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan } function ProjectWorkspacesContent({ + companyId, + projectId, projectRef, summaries, }: { + companyId: string; + projectId: string; projectRef: string; summaries: ReturnType; }) { + const queryClient = useQueryClient(); + const [closingWorkspace, setClosingWorkspace] = useState<{ + id: string; + name: string; + status: ExecutionWorkspace["status"]; + } | null>(null); + if (summaries.length === 0) { return

No non-default workspace activity yet.

; } - return ( -
- {summaries.map((summary) => { - const visibleIssues = summary.issues.slice(0, 3); - const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); - const workspaceHref = - summary.kind === "project_workspace" - ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId) - : `/execution-workspaces/${summary.workspaceId}`; + const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed"); + const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed"); - return ( -
-
-
+ const renderSummaryRow = (summary: ReturnType[number]) => { + const visibleIssues = summary.issues.slice(0, 3); + const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); + const workspaceHref = + summary.kind === "project_workspace" + ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId) + : `/execution-workspaces/${summary.workspaceId}`; + + return ( +
+
+
+ + {summary.workspaceName} + + +
+ + + {summary.branchName ?? "No branch info"} + + {summary.executionWorkspaceStatus ? ( + + {summary.executionWorkspaceStatus} + + ) : null} +
+ + {summary.cwd ? ( +
+ + {summary.cwd} + + + + +
+ ) : null} +
+ +
+
+ Issues ({summary.issues.length}) +
+
+ {visibleIssues.map((issue) => ( - {summary.workspaceName} - - -
- - - {summary.branchName ?? "No branch info"} + + {issue.identifier ?? issue.id.slice(0, 8)} -
- - {summary.cwd ? ( -
- - {summary.cwd} - - - - -
- ) : null} -
- -
-
- Issues ({summary.issues.length}) -
-
- {visibleIssues.map((issue) => ( - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.title} - - ))} - {hiddenIssueCount > 0 ? ( - - ... and {hiddenIssueCount} more - - ) : null} -
-
- -
- - {summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"} + {issue.title} -
- - {timeAgo(summary.lastUpdatedAt)} -
-
+ ))} + {hiddenIssueCount > 0 ? ( + + ... and {hiddenIssueCount} more + + ) : null}
- ); - })} -
+ +
+ + {summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"} + + {summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? ( + + ) : null} +
+ + {timeAgo(summary.lastUpdatedAt)} +
+
+
+
+ ); + }; + + return ( + <> +
+
+ {activeSummaries.map(renderSummaryRow)} +
+ {cleanupFailedSummaries.length > 0 ? ( +
+
+ Cleanup attention needed +
+
+ {cleanupFailedSummaries.map(renderSummaryRow)} +
+
+ ) : null} +
+ {closingWorkspace ? ( + { + if (!open) setClosingWorkspace(null); + }} + onClosed={() => { + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); + setClosingWorkspace(null); + }} + /> + ) : null} + ); } @@ -754,7 +821,12 @@ export function ProjectDetail() { workspaceTabError ? (

{workspaceTabError.message}

) : ( - + ) ) : (

Loading workspaces...