import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; 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 }; } 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), }; 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, }; 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, }; 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, }; } 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, }; } 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)); }, 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 };