Allow shared execution workspace sessions to be archived with warnings instead of hard-blocking on open linked issues, clear issue workspace links when those shared sessions are archived, and update the close dialog copy and coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
643 lines
24 KiB
TypeScript
643 lines
24 KiB
TypeScript
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";
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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));
|
|
return rows.map((row) => toExecutionWorkspace(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 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));
|
|
},
|
|
|
|
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";
|
|
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 };
|