Files
2026-07-13 13:32:57 +08:00

991 lines
31 KiB
TypeScript

import type {
BackgroundWorkerMetadata,
BackgroundWorkerSourceFileMetadata,
CreateBackgroundWorkerRequestBody,
PromptResource,
QueueManifest,
TaskResource,
} from "@trigger.dev/core/v3";
import { tryCatch } from "@trigger.dev/core/v3";
import { BackgroundWorkerId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic";
import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database";
import cronstrue from "cronstrue";
import type { PrismaClientOrTransaction } from "~/db.server";
import { $transaction, Prisma } from "~/db.server";
import { sanitizeQueueName } from "~/models/taskQueue.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
import {
type TaskMetadataCache,
type TaskMetadataEntry,
} from "~/services/taskMetadataCache.server";
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { engine } from "../runEngine.server";
import {
removeQueueConcurrencyLimits,
updateEnvConcurrencyLimits,
updateQueueConcurrencyLimits,
} from "../runQueue.server";
import { scheduleEngine } from "../scheduleEngine.server";
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
import { clampMaxDuration } from "../utils/maxDuration";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { CheckScheduleService } from "./checkSchedule.server";
import { projectPubSub } from "./projectPubSub.server";
import { assertNoDuplicateTaskIds } from "./duplicateTaskIds.server";
import { stripBackgroundWorkerMetadataForStorage } from "./stripBackgroundWorkerMetadataForStorage.server";
export { stripBackgroundWorkerMetadataForStorage };
export class CreateBackgroundWorkerService extends BaseService {
private readonly _taskMetaCache: TaskMetadataCache;
constructor(
prisma?: PrismaClientOrTransaction,
replica?: PrismaClientOrTransaction,
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
) {
super(prisma, replica);
this._taskMetaCache = taskMetaCache;
}
public async call(
projectRef: string,
environment: AuthenticatedEnvironment,
body: CreateBackgroundWorkerRequestBody
): Promise<BackgroundWorker> {
return this.traceWithEnv("call", environment, async (span) => {
span.setAttribute("projectRef", projectRef);
const project = await this._prisma.project.findFirstOrThrow({
where: {
externalRef: projectRef,
environments: {
some: {
id: environment.id,
},
},
},
include: {
backgroundWorkers: {
where: {
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
const latestBackgroundWorker = project.backgroundWorkers[0];
if (latestBackgroundWorker?.contentHash === body.metadata.contentHash) {
return latestBackgroundWorker;
}
const nextVersion = calculateNextBuildVersion(project.backgroundWorkers[0]?.version);
logger.debug(`Creating background worker`, {
nextVersion,
lastVersion: project.backgroundWorkers[0]?.version,
});
const backgroundWorker = await this._prisma.backgroundWorker.create({
data: {
...BackgroundWorkerId.generate(),
version: nextVersion,
runtimeEnvironmentId: environment.id,
projectId: project.id,
metadata: stripBackgroundWorkerMetadataForStorage(body.metadata),
contentHash: body.metadata.contentHash,
cliVersion: body.metadata.cliPackageVersion,
sdkVersion: body.metadata.packageVersion,
runtime: body.metadata.runtime,
runtimeVersion: body.metadata.runtimeVersion,
supportsLazyAttempts: body.supportsLazyAttempts,
engine: body.engine,
},
});
//upgrade the project to engine "V2" if it's not already
if (project.engine === "V1" && body.engine === "V2") {
await this._prisma.project.update({
where: {
id: project.id,
},
data: {
engine: "V2",
},
});
}
const [filesError, tasksToBackgroundFiles] = await tryCatch(
createBackgroundFiles(
body.metadata.sourceFiles,
backgroundWorker,
environment,
this._prisma
)
);
if (filesError) {
logger.error("Error creating background worker files", {
error: filesError,
backgroundWorker,
environment,
});
throw new ServiceValidationError("Error creating background worker files");
}
const [resourcesError, workerTaskEntries] = await tryCatch(
createWorkerResources(
body.metadata,
backgroundWorker,
environment,
this._prisma,
tasksToBackgroundFiles
)
);
if (resourcesError) {
if (resourcesError instanceof ServiceValidationError) {
// Customer-facing config error (e.g. duplicate task ids). Surface the
// real message to the client via the rethrow.
logger.warn("Error creating worker resources", {
error: resourcesError.message,
});
throw resourcesError;
}
logger.error("Error creating worker resources", {
error: resourcesError,
backgroundWorker,
environment,
});
throw new ServiceValidationError("Error creating worker resources");
}
const [schedulesError] = await tryCatch(
syncDeclarativeSchedules(body.metadata.tasks, backgroundWorker, environment, this._prisma)
);
if (schedulesError) {
if (schedulesError instanceof ServiceValidationError) {
// Customer schedule config (typically invalid cron). Surface to
// client via the rethrow; system returns gracefully.
logger.warn("Error syncing declarative schedules", {
error: schedulesError.message,
backgroundWorker,
environment,
});
throw schedulesError;
}
// Wrapping the underlying error into a ServiceValidationError below
// would otherwise hide it once the SDK-level filter drops SVEs; log at
// error so the underlying cause stays visible. Mirrors the
// waitpointCompletionPacket.server.ts pattern from dac9c83bd.
logger.error("Error syncing declarative schedules", {
error: schedulesError,
backgroundWorker,
environment,
});
throw new ServiceValidationError("Error syncing declarative schedules");
}
const [syncIdentifiersError] = await tryCatch(
syncTaskIdentifiers(
environment.id,
project.id,
backgroundWorker.id,
body.metadata.tasks.map((t) => ({ id: t.id, triggerSource: t.triggerSource }))
)
);
if (syncIdentifiersError) {
logger.error("Error syncing task identifiers", {
error: syncIdentifiersError,
backgroundWorker,
environment,
});
}
// Populate task metadata cache. DEV workers are always "current" because
// `findCurrentWorkerFromEnvironment` resolves DEV current as the latest
// worker by createdAt. Non-DEV (deploy-built) workers are not promoted
// here — promotion writes the `:env:` keyspace later in
// changeCurrentDeployment / createDeploymentBackgroundWorkerV3.
// Cache calls log+swallow internally, so a Redis blip can't break
// anything else here. Empty `workerTaskEntries` is intentional — the
// populate methods clear stale hashes for zero-task deploys.
if (workerTaskEntries) {
if (environment.type === "DEVELOPMENT") {
await this._taskMetaCache.populateByCurrentWorker(
environment.id,
backgroundWorker.id,
workerTaskEntries
);
} else {
await this._taskMetaCache.populateByWorker(backgroundWorker.id, workerTaskEntries);
}
}
const [updateConcurrencyLimitsError] = await tryCatch(
updateEnvConcurrencyLimits(environment)
);
if (updateConcurrencyLimitsError) {
logger.error("Error updating environment concurrency limits", {
error: updateConcurrencyLimitsError,
backgroundWorker,
environment,
});
}
const [publishError] = await tryCatch(
projectPubSub.publish(`project:${project.id}:env:${environment.id}`, "WORKER_CREATED", {
environmentId: environment.id,
environmentType: environment.type,
createdAt: backgroundWorker.createdAt,
taskCount: body.metadata.tasks.length,
type: "local",
})
);
if (publishError) {
logger.error("Error publishing WORKER_CREATED event", {
error: publishError,
backgroundWorker,
environment,
});
}
if (backgroundWorker.engine === "V2") {
const [schedulePendingVersionsError] = await tryCatch(
engine.scheduleEnqueueRunsForBackgroundWorker(backgroundWorker.id)
);
if (schedulePendingVersionsError) {
logger.error("Error scheduling pending versions", {
error: schedulePendingVersionsError,
});
}
}
return backgroundWorker;
});
}
}
export async function createWorkerResources(
metadata: BackgroundWorkerMetadata,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction,
tasksToBackgroundFiles?: Map<string, string>
): Promise<TaskMetadataEntry[]> {
// Defense-in-depth against two tasks sharing an id (across all task types,
// e.g. a schedule and a regular task). Note: the CLI's resource catalog keys
// tasks by id and overwrites collisions, so duplicates are normally already
// collapsed before reaching here — this guards against any client that sends
// an un-deduplicated task list.
assertNoDuplicateTaskIds(metadata.tasks);
// Create the queues
const queues = await createWorkerQueues(metadata, worker, environment, prisma);
// Create the tasks
const taskEntries = await createWorkerTasks(
metadata,
queues,
worker,
environment,
prisma,
tasksToBackgroundFiles
);
// Register prompts
if (metadata.prompts && metadata.prompts.length > 0) {
await createWorkerPrompts(metadata.prompts, worker, environment, prisma);
}
return taskEntries;
}
async function createWorkerTasks(
metadata: BackgroundWorkerMetadata,
queues: Array<TaskQueue>,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction,
tasksToBackgroundFiles?: Map<string, string>
): Promise<TaskMetadataEntry[]> {
// Create tasks in chunks of 20
const CHUNK_SIZE = 20;
const entries: TaskMetadataEntry[] = [];
for (let i = 0; i < metadata.tasks.length; i += CHUNK_SIZE) {
const chunk = metadata.tasks.slice(i, i + CHUNK_SIZE);
const chunkEntries = await Promise.all(
chunk.map((task) =>
createWorkerTask(task, queues, worker, environment, prisma, tasksToBackgroundFiles)
)
);
for (const entry of chunkEntries) {
if (entry) entries.push(entry);
}
}
return entries;
}
async function createWorkerTask(
task: TaskResource,
queues: Array<TaskQueue>,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction,
tasksToBackgroundFiles?: Map<string, string>
): Promise<TaskMetadataEntry | null> {
// Hoisted so the P2002 catch branch can return the same entry shape.
let queue: TaskQueue | undefined;
let resolvedTriggerSource: "SCHEDULED" | "AGENT" | "STANDARD" | undefined;
let resolvedTtl: string | null | undefined;
try {
queue = queues.find((queue) => queue.name === task.queue?.name);
if (!queue) {
// Create a TaskQueue
queue = await createWorkerQueue(
{
name: task.queue?.name ?? `task/${task.id}`,
concurrencyLimit: task.queue?.concurrencyLimit,
},
task.id,
task.queue?.name ? "NAMED" : "VIRTUAL",
worker,
environment,
prisma
);
}
resolvedTriggerSource =
task.triggerSource === "schedule"
? ("SCHEDULED" as const)
: task.triggerSource === "agent"
? ("AGENT" as const)
: ("STANDARD" as const);
resolvedTtl =
typeof task.ttl === "number" ? (stringifyDuration(task.ttl) ?? null) : (task.ttl ?? null);
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: worker.projectId,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
workerId: worker.id,
slug: task.id,
description: task.description,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
machineConfig: task.machine,
triggerSource: resolvedTriggerSource,
config: task.agentConfig ? (task.agentConfig as any) : undefined,
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
ttl: resolvedTtl,
queueId: queue.id,
payloadSchema: task.payloadSchema as any,
},
});
return {
slug: task.id,
ttl: resolvedTtl,
triggerSource: resolvedTriggerSource,
queueId: queue.id,
queueName: queue.name,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// The error code for unique constraint violation in Prisma is P2002
if (error.code === "P2002") {
// Retry landing after the first attempt's row was already written.
const existing = await prisma.backgroundWorkerTask.findFirst({
where: { workerId: worker.id, slug: task.id },
select: { id: true },
});
logger.warn("Attempted to recreate background worker task", {
task,
worker,
});
if (existing && queue && resolvedTriggerSource && resolvedTtl !== undefined) {
return {
slug: task.id,
ttl: resolvedTtl,
triggerSource: resolvedTriggerSource,
queueId: queue.id,
queueName: queue.name,
};
}
} else {
logger.error("Prisma Error creating background worker task", {
error: {
code: error.code,
message: error.message,
},
task,
worker,
});
}
} else if (error instanceof Error) {
logger.error("Error creating background worker task", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
task,
worker,
});
} else {
logger.error("Unknown error creating background worker task", {
error,
task,
worker,
});
}
return null;
}
}
async function createWorkerQueues(
metadata: BackgroundWorkerMetadata,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
if (!metadata.queues) {
return [];
}
const CHUNK_SIZE = 20;
const allQueues: Awaited<ReturnType<typeof createWorkerQueue>>[] = [];
// Process queues in chunks
for (let i = 0; i < metadata.queues.length; i += CHUNK_SIZE) {
const chunk = metadata.queues.slice(i, i + CHUNK_SIZE);
const queueChunk = await Promise.all(
chunk.map(async (queue) => {
return createWorkerQueue(queue, queue.name, "NAMED", worker, environment, prisma);
})
);
allQueues.push(...queueChunk.filter(Boolean));
}
return allQueues;
}
async function createWorkerQueue(
queue: QueueManifest,
orderableName: string,
queueType: TaskQueueType,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
let queueName = sanitizeQueueName(queue.name);
const baseConcurrencyLimit =
typeof queue.concurrencyLimit === "number"
? Math.max(Math.min(queue.concurrencyLimit, environment.maximumConcurrencyLimit), 0)
: queue.concurrencyLimit;
const taskQueue = await upsertWorkerQueueRecord(
queueName,
baseConcurrencyLimit ?? null,
orderableName,
queueType,
worker,
prisma
);
const newConcurrencyLimit = taskQueue.concurrencyLimit;
if (!taskQueue.paused) {
if (typeof newConcurrencyLimit === "number") {
logger.debug("createWorkerQueue: updating concurrency limit", {
workerId: worker.id,
taskQueue,
orgId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
concurrencyLimit: newConcurrencyLimit,
});
await updateQueueConcurrencyLimits(environment, taskQueue.name, newConcurrencyLimit);
} else {
logger.debug("createWorkerQueue: removing concurrency limit", {
workerId: worker.id,
taskQueue,
orgId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
concurrencyLimit: newConcurrencyLimit,
});
await removeQueueConcurrencyLimits(environment, taskQueue.name);
}
} else {
logger.debug("createWorkerQueue: queue is paused, not updating concurrency limit", {
workerId: worker.id,
taskQueue,
orgId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
});
}
return taskQueue;
}
async function upsertWorkerQueueRecord(
queueName: string,
concurrencyLimit: number | null,
orderableName: string,
queueType: TaskQueueType,
worker: BackgroundWorker,
prisma: PrismaClientOrTransaction,
attempt: number = 0
): Promise<TaskQueue> {
if (attempt > 3) {
throw new Error("Failed to insert queue record");
}
try {
let taskQueue = await prisma.taskQueue.findFirst({
where: {
runtimeEnvironmentId: worker.runtimeEnvironmentId,
name: queueName,
},
});
if (!taskQueue) {
taskQueue = await prisma.taskQueue.create({
data: {
friendlyId: generateFriendlyId("queue"),
version: "V2",
name: queueName,
orderableName,
concurrencyLimit,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
projectId: worker.projectId,
type: queueType,
workers: {
connect: {
id: worker.id,
},
},
},
});
} else {
const hasOverride = taskQueue.concurrencyLimitOverriddenAt !== null;
taskQueue = await prisma.taskQueue.update({
where: {
id: taskQueue.id,
},
data: {
workers: { connect: { id: worker.id } },
version: "V2",
orderableName,
// If overridden, keep current limit and update base; otherwise update limit normally
concurrencyLimit: hasOverride ? undefined : concurrencyLimit,
concurrencyLimitBase: hasOverride ? concurrencyLimit : undefined,
},
});
}
return taskQueue;
} catch (error) {
// If the queue already exists, let's try again
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return await upsertWorkerQueueRecord(
queueName,
concurrencyLimit,
orderableName,
queueType,
worker,
prisma,
attempt + 1
);
}
throw error;
}
}
//CreateDeclarativeScheduleError with a message
export class CreateDeclarativeScheduleError extends Error {
constructor(message: string) {
super(message);
this.name = "CreateDeclarativeScheduleError";
}
}
export async function syncDeclarativeSchedules(
tasks: TaskResource[],
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
const tasksWithDeclarativeSchedules = tasks.filter((task) => task.schedule);
logger.info("Syncing declarative schedules", {
tasksWithDeclarativeSchedules,
environment,
});
const existingDeclarativeSchedules = await prisma.taskSchedule.findMany({
where: {
type: "DECLARATIVE",
projectId: environment.projectId,
},
include: {
instances: true,
},
});
const checkSchedule = new CheckScheduleService(prisma);
//start out by assuming they're all missing
const missingSchedules = new Set<string>(
existingDeclarativeSchedules.map((schedule) => schedule.id)
);
//create/update schedules (+ instances)
for (const task of tasksWithDeclarativeSchedules) {
if (task.schedule === undefined) continue;
// Check if this schedule should be created in the current environment
if (task.schedule.environments && task.schedule.environments.length > 0) {
if (!task.schedule.environments.includes(environment.type)) {
logger.debug("Skipping schedule creation due to environment filter", {
taskId: task.id,
environmentType: environment.type,
allowedEnvironments: task.schedule.environments,
});
continue;
}
}
const existingSchedule = existingDeclarativeSchedules.find(
(schedule) =>
schedule.taskIdentifier === task.id &&
schedule.instances.some((instance) => instance.environmentId === environment.id)
);
//this throws errors if the schedule is invalid
await checkSchedule.call(
environment.projectId,
{
cron: task.schedule.cron,
timezone: task.schedule.timezone,
taskIdentifier: task.id,
friendlyId: existingSchedule?.friendlyId,
},
[environment.id]
);
if (existingSchedule) {
const schedule = await prisma.taskSchedule.update({
where: {
id: existingSchedule.id,
},
data: {
generatorExpression: task.schedule.cron,
generatorDescription: cronstrue.toString(task.schedule.cron),
timezone: task.schedule.timezone,
},
include: {
instances: true,
},
});
missingSchedules.delete(existingSchedule.id);
const instance = schedule.instances.at(0);
if (instance) {
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id });
} else {
throw new CreateDeclarativeScheduleError(
`Missing instance for declarative schedule ${schedule.id}`
);
}
} else {
const newSchedule = await prisma.taskSchedule.create({
data: {
friendlyId: generateFriendlyId("sched"),
projectId: environment.projectId,
taskIdentifier: task.id,
generatorExpression: task.schedule.cron,
generatorDescription: cronstrue.toString(task.schedule.cron),
timezone: task.schedule.timezone,
type: "DECLARATIVE",
instances: {
create: [
{
environmentId: environment.id,
projectId: environment.projectId,
},
],
},
},
include: {
instances: true,
},
});
const instance = newSchedule.instances.at(0);
if (instance) {
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id });
} else {
throw new CreateDeclarativeScheduleError(
`Missing instance for declarative schedule ${newSchedule.id}`
);
}
}
}
//Delete instances for this environment
//Delete schedules that have no instances left
const potentiallyDeletableSchedules = await prisma.taskSchedule.findMany({
where: {
id: {
in: Array.from(missingSchedules),
},
},
include: {
instances: true,
},
});
for (const schedule of potentiallyDeletableSchedules) {
const canDeleteSchedule =
schedule.instances.length === 0 ||
schedule.instances.every((instance) => instance.environmentId === environment.id);
if (canDeleteSchedule) {
//we can delete schedules with no instances other than ones for the current environment
await prisma.taskSchedule.delete({
where: {
id: schedule.id,
},
});
} else {
//otherwise we delete the instance (other environments remain untouched)
await prisma.taskScheduleInstance.deleteMany({
where: {
taskScheduleId: schedule.id,
environmentId: environment.id,
},
});
}
}
}
export async function createBackgroundFiles(
files: Array<BackgroundWorkerSourceFileMetadata> | undefined,
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
// Maps from each taskId to the backgroundWorkerFileId
const results = new Map<string, string>();
if (!files) {
return results;
}
for (const file of files) {
const backgroundWorkerFile = await prisma.backgroundWorkerFile.upsert({
where: {
projectId_contentHash: {
projectId: environment.projectId,
contentHash: file.contentHash,
},
},
create: {
friendlyId: generateFriendlyId("file"),
projectId: environment.projectId,
contentHash: file.contentHash,
filePath: file.filePath,
contents: Buffer.from(file.contents),
backgroundWorkers: {
connect: {
id: worker.id,
},
},
},
update: {
backgroundWorkers: {
connect: {
id: worker.id,
},
},
},
});
for (const taskId of file.taskIds) {
results.set(taskId, backgroundWorkerFile.id);
}
}
return results;
}
import { createHash } from "crypto";
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
async function createWorkerPrompts(
prompts: PromptResource[],
worker: BackgroundWorker,
environment: AuthenticatedEnvironment,
prisma: PrismaClientOrTransaction
) {
for (const promptResource of prompts) {
try {
// Upsert the Prompt record (identity + schema)
const prompt = await prisma.prompt.upsert({
where: {
projectId_runtimeEnvironmentId_slug: {
projectId: worker.projectId,
runtimeEnvironmentId: environment.id,
slug: promptResource.id,
},
},
create: {
friendlyId: generateFriendlyId("prompt"),
organizationId: environment.organizationId,
projectId: worker.projectId,
runtimeEnvironmentId: environment.id,
slug: promptResource.id,
description: promptResource.description,
filePath: promptResource.filePath,
exportName: promptResource.exportName,
variableSchema: promptResource.variableSchema as any,
defaultModel: promptResource.model,
defaultConfig: promptResource.config as any,
},
update: {
description: promptResource.description,
filePath: promptResource.filePath,
exportName: promptResource.exportName,
variableSchema: promptResource.variableSchema as any,
defaultModel: promptResource.model,
defaultConfig: promptResource.config as any,
},
});
// Compute the version-definition hash for dedup. Includes the model and
// config, not just the prompt text, so changing a code prompt's model or
// config creates a new version — otherwise a model-only change is silently
// skipped and the old model keeps serving.
const contentString = promptResource.content ?? "";
const contentHash = hashContent(
JSON.stringify({
content: contentString,
model: promptResource.model ?? null,
config: promptResource.config ?? null,
})
);
// Find the latest version overall (for version numbering) and the latest
// code-sourced version (for content dedup). We compare against the latest
// code version specifically so that dashboard edits don't interfere with
// dedup — if the code hasn't changed since the last deploy, we skip even
// if a dashboard edit happened in between.
const latestVersion = await prisma.promptVersion.findFirst({
where: { promptId: prompt.id },
orderBy: { version: "desc" },
});
const latestCodeVersion = await prisma.promptVersion.findFirst({
where: { promptId: prompt.id, source: "code" },
orderBy: { version: "desc" },
});
if (latestCodeVersion?.contentHash === contentHash) {
// Code definition (text + model + config) unchanged since last deploy —
// skip creating a new version.
continue;
}
const nextVersion = (latestVersion?.version ?? 0) + 1;
// Determine labels for the new version.
// Deploys always move "current" to the new code version. If a dashboard
// override exists, it sits on top via the "override" label and the API
// serves that instead — so "current" movement is safe.
const labels = ["latest", "current"];
// Wrap label removal + version creation in a transaction so labels
// aren't stripped if the create fails (e.g. concurrent deploy race).
await $transaction(prisma, async (tx) => {
// Remove "latest" label from all existing versions
if (latestVersion) {
await tx.$executeRaw`
UPDATE "prompt_versions"
SET "labels" = array_remove("labels", 'latest')
WHERE "promptId" = ${prompt.id} AND 'latest' = ANY("labels")
`;
}
// Remove "current" from any existing version
await tx.$executeRaw`
UPDATE "prompt_versions"
SET "labels" = array_remove("labels", 'current')
WHERE "promptId" = ${prompt.id} AND 'current' = ANY("labels")
`;
await tx.promptVersion.create({
data: {
promptId: prompt.id,
version: nextVersion,
textContent: contentString,
model: promptResource.model,
config: promptResource.config as any,
source: "code",
contentHash,
labels,
workerId: worker.id,
},
});
});
logger.debug("Registered prompt version", {
promptSlug: promptResource.id,
version: nextVersion,
labels,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
logger.warn("Prompt version already exists", { prompt: promptResource.id });
} else {
logger.error("Error creating prompt version", {
error: error instanceof Error ? error.message : String(error),
prompt: promptResource.id,
});
}
}
}
}