import { json } from "@remix-run/server-runtime"; import { Ratelimit } from "@upstash/ratelimit"; import { tryCatch } from "@trigger.dev/core"; import { DevDisconnectRequestBody } from "@trigger.dev/core/v3"; import { BulkActionId, RunId } from "@trigger.dev/core/v3/isomorphic"; import { BulkActionNotificationType, BulkActionType } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { runStore } from "~/v3/runStore.server"; import { logger } from "~/services/logger.server"; import { RateLimiter } from "~/services/rateLimiter.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { commonWorker } from "~/v3/commonWorker.server"; import pMap from "p-map"; const CANCEL_REASON = "Dev session ended (CLI exited)"; // Below this threshold, cancel runs inline with pMap. // Above it, create a bulk action and process asynchronously. const BULK_ACTION_THRESHOLD = 25; // Maximum number of runs that can be cancelled in a single disconnect call. const MAX_RUNS = 500; // Rate limit: 5 calls per minute per environment const disconnectRateLimiter = new RateLimiter({ keyPrefix: "dev-disconnect", limiter: Ratelimit.fixedWindow(5, "1 m"), logFailure: true, }); const { action } = createActionApiRoute( { body: DevDisconnectRequestBody, maxContentLength: 1024 * 256, // 256KB method: "POST", }, async ({ authentication, body }) => { // Only allow dev environments — this endpoint uses finalizeRun which // skips PENDING_CANCEL and immediately finalizes executing runs. if (authentication.environment.type !== "DEVELOPMENT") { return json( { error: "This endpoint is only available for dev environments" }, { status: 403 } ); } const environmentId = authentication.environment.id; // Rate limit per environment const rateLimitResult = await disconnectRateLimiter.limit(environmentId); if (!rateLimitResult.success) { return json( { error: "Rate limit exceeded", retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000), }, { status: 429 } ); } if (body.runFriendlyIds.length > MAX_RUNS) { return json( { error: `A maximum of ${MAX_RUNS} runs can be cancelled per request` }, { status: 400 } ); } const { runFriendlyIds } = body; if (runFriendlyIds.length === 0) { return json({ cancelled: 0 }, { status: 200 }); } logger.info("Dev disconnect: cancelling runs", { environmentId, runCount: runFriendlyIds.length, }); // For small numbers of runs, cancel inline if (runFriendlyIds.length <= BULK_ACTION_THRESHOLD) { const cancelled = await cancelRunsInline(runFriendlyIds, environmentId); return json({ cancelled }, { status: 200 }); } // For large numbers, create a bulk action to process asynchronously const bulkActionId = await createBulkCancelAction( runFriendlyIds, authentication.environment.project.id, environmentId ); logger.info("Dev disconnect: created bulk action for large run set", { environmentId, bulkActionId, runCount: runFriendlyIds.length, }); return json({ cancelled: 0, bulkActionId }, { status: 200 }); } ); async function cancelRunsInline(runFriendlyIds: string[], environmentId: string): Promise { const runIds = runFriendlyIds.map((fid) => RunId.toId(fid)); const runs = await runStore.findRuns( { where: { id: { in: runIds }, runtimeEnvironmentId: environmentId, }, select: { id: true, engine: true, friendlyId: true, status: true, createdAt: true, completedAt: true, taskEventStore: true, }, }, prisma ); let cancelled = 0; const cancelService = new CancelTaskRunService(prisma); await pMap( runs, async (run) => { const [error, result] = await tryCatch( cancelService.call(run, { reason: CANCEL_REASON, finalizeRun: true }) ); if (error) { logger.error("Dev disconnect: failed to cancel run", { runId: run.id, error, }); } else if (result && !result.alreadyFinished) { cancelled++; } }, { concurrency: 10 } ); logger.info("Dev disconnect: completed inline cancellation", { environmentId, cancelled, total: runFriendlyIds.length, }); return cancelled; } async function createBulkCancelAction( runFriendlyIds: string[], projectId: string, environmentId: string ): Promise { const { id, friendlyId } = BulkActionId.generate(); await prisma.bulkActionGroup.create({ data: { id, friendlyId, projectId, environmentId, name: "Dev session disconnect", type: BulkActionType.CANCEL, params: { runId: runFriendlyIds, finalizeRun: true }, queryName: "bulk_action_v1", totalCount: runFriendlyIds.length, completionNotification: BulkActionNotificationType.NONE, }, }); await commonWorker.enqueue({ id: `processBulkAction-${id}`, job: "processBulkAction", payload: { bulkActionId: id }, }); return friendlyId; } export { action };