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 12:15:34 -05:00
|
|
|
import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
|
|
|
|
import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared";
|
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-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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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<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
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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 };
|