| 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 "@penclipai/db"; |
| import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@penclipai/db"; |
| import type { |
| ExecutionWorkspace, |
| ExecutionWorkspaceSummary, |
| ExecutionWorkspaceCloseAction, |
| ExecutionWorkspaceCloseGitReadiness, |
| ExecutionWorkspaceCloseReadiness, |
| ExecutionWorkspaceConfig, |
| WorkspaceRuntimeDesiredState, |
| WorkspaceRuntimeService, |
| } from "@penclipai/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 }; |
| } |
|
|
| function readDesiredState(value: unknown): WorkspaceRuntimeDesiredState | null { |
| return value === "running" || value === "stopped" || value === "manual" ? value : null; |
| } |
|
|
| function readServiceStates(value: unknown): ExecutionWorkspaceConfig["serviceStates"] { |
| if (!isRecord(value)) return null; |
| const entries = Object.entries(value).filter(([, state]) => |
| state === "running" || state === "stopped" || state === "manual" |
| ); |
| return entries.length > 0 |
| ? Object.fromEntries(entries) as ExecutionWorkspaceConfig["serviceStates"] |
| : null; |
| } |
|
|
| 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 = { |
| environmentId: readNullableString(raw.environmentId), |
| provisionCommand: readNullableString(raw.provisionCommand), |
| teardownCommand: readNullableString(raw.teardownCommand), |
| cleanupCommand: readNullableString(raw.cleanupCommand), |
| workspaceRuntime: cloneRecord(raw.workspaceRuntime), |
| desiredState: readDesiredState(raw.desiredState), |
| serviceStates: readServiceStates(raw.serviceStates), |
| }; |
|
|
| 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) ?? { |
| environmentId: null, |
| provisionCommand: null, |
| teardownCommand: null, |
| cleanupCommand: null, |
| workspaceRuntime: null, |
| desiredState: null, |
| serviceStates: null, |
| }; |
|
|
| if (patch === null) { |
| delete nextMetadata.config; |
| return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; |
| } |
|
|
| const nextConfig: ExecutionWorkspaceConfig = { |
| environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId, |
| 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 |
| ? readDesiredState(patch.desiredState) |
| : current.desiredState, |
| serviceStates: |
| patch.serviceStates !== undefined ? readServiceStates(patch.serviceStates) : current.serviceStates, |
| }; |
|
|
| 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 = { |
| environmentId: nextConfig.environmentId, |
| provisionCommand: nextConfig.provisionCommand, |
| teardownCommand: nextConfig.teardownCommand, |
| cleanupCommand: nextConfig.cleanupCommand, |
| workspaceRuntime: nextConfig.workspaceRuntime, |
| desiredState: nextConfig.desiredState, |
| serviceStates: nextConfig.serviceStates ?? null, |
| }; |
| } 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 toExecutionWorkspaceSummary(row: Pick<ExecutionWorkspaceRow, "id" | "name" | "mode" | "projectWorkspaceId">): ExecutionWorkspaceSummary { |
| return { |
| id: row.id, |
| name: row.name, |
| mode: row.mode as ExecutionWorkspaceSummary["mode"], |
| projectWorkspaceId: row.projectWorkspaceId ?? null, |
| }; |
| } |
|
|
| 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) { |
| function buildListConditions( |
| 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"])); |
| } |
| return conditions; |
| } |
|
|
| return { |
| list: async (companyId: string, filters?: { |
| projectId?: string; |
| projectWorkspaceId?: string; |
| issueId?: string; |
| status?: string; |
| reuseEligible?: boolean; |
| }) => { |
| const conditions = buildListConditions(companyId, filters); |
| 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), |
| ), |
| ); |
| }, |
|
|
| listSummaries: async (companyId: string, filters?: { |
| projectId?: string; |
| projectWorkspaceId?: string; |
| issueId?: string; |
| status?: string; |
| reuseEligible?: boolean; |
| }) => { |
| const conditions = buildListConditions(companyId, filters); |
| const rows = await db |
| .select({ |
| id: executionWorkspaces.id, |
| name: executionWorkspaces.name, |
| mode: executionWorkspaces.mode, |
| projectWorkspaceId: executionWorkspaces.projectWorkspaceId, |
| }) |
| .from(executionWorkspaces) |
| .where(and(...conditions)) |
| .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); |
| return rows.map((row) => toExecutionWorkspaceSummary(row)); |
| }, |
|
|
| 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; |
| }, |
|
|
| clearEnvironmentSelection: async (companyId: string, environmentId: string) => { |
| return db.transaction(async (tx) => { |
| const rows = await tx |
| .select({ |
| id: executionWorkspaces.id, |
| metadata: executionWorkspaces.metadata, |
| }) |
| .from(executionWorkspaces) |
| .where(eq(executionWorkspaces.companyId, companyId)); |
|
|
| let cleared = 0; |
| const updatedAt = new Date(); |
| for (const row of rows) { |
| const metadata = (row.metadata as Record<string, unknown> | null) ?? null; |
| const config = readExecutionWorkspaceConfig(metadata); |
| if (config?.environmentId !== environmentId) continue; |
|
|
| await tx |
| .update(executionWorkspaces) |
| .set({ |
| metadata: mergeExecutionWorkspaceConfig(metadata, { environmentId: null }), |
| updatedAt, |
| }) |
| .where(eq(executionWorkspaces.id, row.id)); |
| cleared += 1; |
| } |
|
|
| return cleared; |
| }); |
| }, |
| }; |
| } |
|
|
| export { toExecutionWorkspace }; |
|
|