Spaces:
Paused
Paused
| 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, 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"; | |
| import { | |
| listCurrentRuntimeServicesForExecutionWorkspaces, | |
| listCurrentRuntimeServicesForProjectWorkspaces, | |
| } from "./workspace-runtime-read-model.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<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| function readNullableString(value: unknown): string | null { | |
| if (typeof value !== "string") return null; | |
| const trimmed = value.trim(); | |
| return trimmed.length > 0 ? trimmed : null; | |
| } | |
| function cloneRecord(value: unknown): Record<string, unknown> | null { | |
| if (!isRecord(value)) return 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<string, unknown> | null | undefined): ExecutionWorkspaceConfig | null { | |
| const raw = isRecord(metadata?.config) ? metadata.config : null; | |
| if (!raw) return null; | |
| const config: ExecutionWorkspaceConfig = { | |
| provisionCommand: readNullableString(raw.provisionCommand), | |
| teardownCommand: readNullableString(raw.teardownCommand), | |
| cleanupCommand: readNullableString(raw.cleanupCommand), | |
| workspaceRuntime: cloneRecord(raw.workspaceRuntime), | |
| desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, | |
| }; | |
| const hasConfig = Object.values(config).some((value) => { | |
| if (value === null) return false; | |
| if (typeof value === "object") return Object.keys(value).length > 0; | |
| return true; | |
| }); | |
| return hasConfig ? config : null; | |
| } | |
| export function mergeExecutionWorkspaceConfig( | |
| metadata: Record<string, unknown> | null | undefined, | |
| patch: Partial<ExecutionWorkspaceConfig> | null, | |
| ): Record<string, unknown> | null { | |
| const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; | |
| const current = readExecutionWorkspaceConfig(metadata) ?? { | |
| provisionCommand: null, | |
| teardownCommand: null, | |
| cleanupCommand: null, | |
| workspaceRuntime: null, | |
| desiredState: null, | |
| }; | |
| if (patch === null) { | |
| delete nextMetadata.config; | |
| return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; | |
| } | |
| const nextConfig: ExecutionWorkspaceConfig = { | |
| provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, | |
| teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, | |
| cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, | |
| workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, | |
| desiredState: | |
| patch.desiredState !== undefined | |
| ? patch.desiredState === "running" || patch.desiredState === "stopped" | |
| ? patch.desiredState | |
| : null | |
| : current.desiredState, | |
| }; | |
| const hasConfig = Object.values(nextConfig).some((value) => { | |
| if (value === null) return false; | |
| if (typeof value === "object") return Object.keys(value).length > 0; | |
| return true; | |
| }); | |
| if (hasConfig) { | |
| nextMetadata.config = { | |
| provisionCommand: nextConfig.provisionCommand, | |
| teardownCommand: nextConfig.teardownCommand, | |
| cleanupCommand: nextConfig.cleanupCommand, | |
| workspaceRuntime: nextConfig.workspaceRuntime, | |
| desiredState: nextConfig.desiredState, | |
| }; | |
| } else { | |
| delete nextMetadata.config; | |
| } | |
| return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; | |
| } | |
| function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService { | |
| return { | |
| id: row.id, | |
| companyId: row.companyId, | |
| projectId: row.projectId ?? null, | |
| projectWorkspaceId: row.projectWorkspaceId ?? null, | |
| executionWorkspaceId: row.executionWorkspaceId ?? null, | |
| issueId: row.issueId ?? null, | |
| scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"], | |
| scopeId: row.scopeId ?? null, | |
| serviceName: row.serviceName, | |
| status: row.status as WorkspaceRuntimeService["status"], | |
| lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"], | |
| reuseKey: row.reuseKey ?? null, | |
| command: row.command ?? null, | |
| cwd: row.cwd ?? null, | |
| port: row.port ?? null, | |
| url: row.url ?? null, | |
| provider: row.provider as WorkspaceRuntimeService["provider"], | |
| providerRef: row.providerRef ?? null, | |
| ownerAgentId: row.ownerAgentId ?? null, | |
| startedByRunId: row.startedByRunId ?? null, | |
| lastUsedAt: row.lastUsedAt, | |
| startedAt: row.startedAt, | |
| stoppedAt: row.stoppedAt ?? null, | |
| stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null, | |
| healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], | |
| createdAt: row.createdAt, | |
| updatedAt: row.updatedAt, | |
| }; | |
| } | |
| function toExecutionWorkspace( | |
| row: ExecutionWorkspaceRow, | |
| runtimeServices: WorkspaceRuntimeService[] = [], | |
| ): ExecutionWorkspace { | |
| return { | |
| id: row.id, | |
| companyId: row.companyId, | |
| projectId: row.projectId, | |
| projectWorkspaceId: row.projectWorkspaceId ?? null, | |
| sourceIssueId: row.sourceIssueId ?? null, | |
| mode: row.mode as ExecutionWorkspace["mode"], | |
| strategyType: row.strategyType as ExecutionWorkspace["strategyType"], | |
| name: row.name, | |
| status: row.status as ExecutionWorkspace["status"], | |
| cwd: row.cwd ?? null, | |
| repoUrl: row.repoUrl ?? null, | |
| baseRef: row.baseRef ?? null, | |
| branchName: row.branchName ?? null, | |
| providerType: row.providerType as ExecutionWorkspace["providerType"], | |
| providerRef: row.providerRef ?? null, | |
| derivedFromExecutionWorkspaceId: row.derivedFromExecutionWorkspaceId ?? null, | |
| lastUsedAt: row.lastUsedAt, | |
| openedAt: row.openedAt, | |
| closedAt: row.closedAt ?? null, | |
| cleanupEligibleAt: row.cleanupEligibleAt ?? null, | |
| cleanupReason: row.cleanupReason ?? null, | |
| config: readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null), | |
| metadata: (row.metadata as Record<string, unknown> | null) ?? null, | |
| runtimeServices, | |
| createdAt: row.createdAt, | |
| updatedAt: row.updatedAt, | |
| }; | |
| } | |
| function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) { | |
| if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false; | |
| return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime; | |
| } | |
| async function loadEffectiveRuntimeServicesByExecutionWorkspace( | |
| db: Db, | |
| companyId: string, | |
| rows: ExecutionWorkspaceRow[], | |
| ) { | |
| const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces( | |
| db, | |
| companyId, | |
| rows.map((row) => row.id), | |
| ); | |
| const projectWorkspaceIds = rows | |
| .filter((row) => usesInheritedProjectRuntimeServices(row)) | |
| .map((row) => row.projectWorkspaceId) | |
| .filter((value): value is string => Boolean(value)); | |
| const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces( | |
| db, | |
| companyId, | |
| [...new Set(projectWorkspaceIds)], | |
| ); | |
| return new Map( | |
| rows.map((row) => [ | |
| row.id, | |
| usesInheritedProjectRuntimeServices(row) | |
| ? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? []) | |
| : (executionRuntimeServices.get(row.id) ?? []), | |
| ]), | |
| ); | |
| } | |
| export function executionWorkspaceService(db: Db) { | |
| return { | |
| list: async (companyId: string, filters?: { | |
| projectId?: string; | |
| projectWorkspaceId?: string; | |
| issueId?: string; | |
| status?: string; | |
| reuseEligible?: boolean; | |
| }) => { | |
| const conditions = [eq(executionWorkspaces.companyId, companyId)]; | |
| if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId)); | |
| if (filters?.projectWorkspaceId) { | |
| conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId)); | |
| } | |
| if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId)); | |
| if (filters?.status) { | |
| const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean); | |
| if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!)); | |
| else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses)); | |
| } | |
| if (filters?.reuseEligible) { | |
| conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"])); | |
| } | |
| const rows = await db | |
| .select() | |
| .from(executionWorkspaces) | |
| .where(and(...conditions)) | |
| .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); | |
| const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows); | |
| return rows.map((row) => | |
| toExecutionWorkspace( | |
| row, | |
| (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), | |
| ), | |
| ); | |
| }, | |
| getById: async (id: string) => { | |
| const row = await db | |
| .select() | |
| .from(executionWorkspaces) | |
| .where(eq(executionWorkspaces.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!row) return null; | |
| const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); | |
| return toExecutionWorkspace( | |
| row, | |
| (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService), | |
| ); | |
| }, | |
| getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => { | |
| const workspace = await db | |
| .select() | |
| .from(executionWorkspaces) | |
| .where(eq(executionWorkspaces.id, id)) | |
| .then((rows) => rows[0] ?? null); | |
| if (!workspace) return null; | |
| const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]); | |
| const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).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<string, unknown> | null) ?? null); | |
| const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace); | |
| const warnings = [...gitWarnings]; | |
| const blockingReasons: string[] = []; | |
| const isSharedWorkspace = executionWorkspace.mode === "shared_workspace"; | |
| const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd); | |
| const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null; | |
| const resolvedPrimaryWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; | |
| const isProjectPrimaryWorkspace = | |
| workspace.projectWorkspaceId != null | |
| && workspace.projectWorkspaceId === primaryProjectWorkspace?.id | |
| && resolvedWorkspacePath != null | |
| && resolvedPrimaryWorkspacePath != null | |
| && resolvedWorkspacePath === resolvedPrimaryWorkspacePath; | |
| const linkedIssueSummaries = linkedIssues.map((issue) => ({ | |
| ...issue, | |
| isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status), | |
| })); | |
| const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal); | |
| if (blockingIssues.length > 0) { | |
| const linkedIssueMessage = | |
| blockingIssues.length === 1 | |
| ? "This workspace is still linked to an open issue." | |
| : `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) { | |
| 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")) { | |
| 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, | |
| }); | |
| } | |
| 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) | |
| .values(data) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return row ? toExecutionWorkspace(row) : null; | |
| }, | |
| update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => { | |
| const row = await db | |
| .update(executionWorkspaces) | |
| .set({ ...patch, updatedAt: new Date() }) | |
| .where(eq(executionWorkspaces.id, id)) | |
| .returning() | |
| .then((rows) => rows[0] ?? null); | |
| return row ? toExecutionWorkspace(row) : null; | |
| }, | |
| }; | |
| } | |
| export { toExecutionWorkspace }; | |