2026-03-28 16:15:20 -05:00
import { execFile } from "node:child_process" ;
import fs from "node:fs/promises" ;
import path from "node:path" ;
import { promisify } from "node:util" ;
2026-03-13 17:12:25 -05:00
import { and , desc , eq , inArray } from "drizzle-orm" ;
import type { Db } from "@paperclipai/db" ;
2026-03-28 16:15:20 -05:00
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" ;
2026-03-13 17:12:25 -05:00
type ExecutionWorkspaceRow = typeof executionWorkspaces . $inferSelect ;
2026-03-28 12:15:34 -05:00
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices . $inferSelect ;
2026-03-28 16:15:20 -05:00
const execFileAsync = promisify ( execFile ) ;
const TERMINAL_ISSUE_STATUSES = new Set ( [ "done" , "cancelled" ] ) ;
2026-03-13 17:12:25 -05:00
2026-03-28 12:15:34 -05:00
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 } ;
}
2026-03-28 16:15:20 -05:00
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 ,
} ;
}
2026-03-28 12:15:34 -05:00
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 ) ,
2026-03-28 16:46:43 -05:00
desiredState : raw.desiredState === "running" || raw . desiredState === "stopped" ? raw.desiredState : null ,
2026-03-28 12:15:34 -05:00
} ;
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 ,
2026-03-28 16:46:43 -05:00
desiredState : null ,
2026-03-28 12:15:34 -05:00
} ;
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 ,
2026-03-28 16:46:43 -05:00
desiredState :
patch . desiredState !== undefined
? patch . desiredState === "running" || patch . desiredState === "stopped"
? patch . desiredState
: null
: current . desiredState ,
2026-03-28 12:15:34 -05:00
} ;
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 ,
2026-03-28 16:46:43 -05:00
desiredState : nextConfig.desiredState ,
2026-03-28 12:15:34 -05:00
} ;
} 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 {
2026-03-13 17:12:25 -05:00
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 ,
2026-03-28 12:15:34 -05:00
config : readExecutionWorkspaceConfig ( ( row . metadata as Record < string , unknown > | null ) ? ? null ) ,
2026-03-13 17:12:25 -05:00
metadata : ( row . metadata as Record < string , unknown > | null ) ? ? null ,
2026-03-28 12:15:34 -05:00
runtimeServices ,
2026-03-13 17:12:25 -05:00
createdAt : row.createdAt ,
updatedAt : row.updatedAt ,
} ;
}
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 ) ) ;
2026-03-28 12:15:34 -05:00
return rows . map ( ( row ) = > toExecutionWorkspace ( row ) ) ;
2026-03-13 17:12:25 -05:00
} ,
getById : async ( id : string ) = > {
const row = await db
. select ( )
. from ( executionWorkspaces )
. where ( eq ( executionWorkspaces . id , id ) )
. then ( ( rows ) = > rows [ 0 ] ? ? null ) ;
2026-03-28 12:15:34 -05:00
if ( ! row ) return null ;
const runtimeServiceRows = await db
. select ( )
. from ( workspaceRuntimeServices )
. where ( eq ( workspaceRuntimeServices . executionWorkspaceId , row . id ) )
. orderBy ( desc ( workspaceRuntimeServices . updatedAt ) , desc ( workspaceRuntimeServices . createdAt ) ) ;
return toExecutionWorkspace ( row , runtimeServiceRows . map ( toRuntimeService ) ) ;
2026-03-13 17:12:25 -05:00
} ,
2026-03-28 16:15:20 -05:00
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 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 < string , unknown > | null ) ? ? null ) ;
const { git , warnings : gitWarnings } = await inspectGitCloseReadiness ( executionWorkspace ) ;
const warnings = [ . . . gitWarnings ] ;
const blockingReasons : string [ ] = [ ] ;
const isSharedWorkspace = executionWorkspace . mode === "shared_workspace" ;
2026-03-28 17:38:34 -05:00
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 ;
2026-03-28 16:15:20 -05:00
const linkedIssueSummaries = linkedIssues . map ( ( issue ) = > ( {
. . . issue ,
isTerminal : TERMINAL_ISSUE_STATUSES.has ( issue . status ) ,
} ) ) ;
const blockingIssues = linkedIssueSummaries . filter ( ( issue ) = > ! issue . isTerminal ) ;
if ( blockingIssues . length > 0 ) {
2026-03-28 19:19:49 -05:00
const linkedIssueMessage =
2026-03-28 16:15:20 -05:00
blockingIssues . length === 1
? "This workspace is still linked to an open issue."
2026-03-28 19:19:49 -05:00
: ` 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 ) ;
}
2026-03-28 16:15:20 -05:00
}
if ( isSharedWorkspace ) {
2026-03-28 19:19:49 -05:00
warnings . push ( "This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record." ) ;
2026-03-28 16:15:20 -05:00
}
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 ,
} ;
} ,
2026-03-13 17:12:25 -05:00
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 } ;