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 { 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 | 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 | 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 | null | undefined, patch: Partial | null, ): Record | 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 | 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 | null) ?? null), metadata: (row.metadata as Record | 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 | 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 => { 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 | 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) => { 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 };