diff --git a/.changeset/strange-moles-provide.md b/.changeset/strange-moles-provide.md new file mode 100644 index 00000000000..3ad85ea0739 --- /dev/null +++ b/.changeset/strange-moles-provide.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Auto-cancel in-flight dev runs when the CLI exits, using a detached watchdog process that survives pnpm SIGKILL diff --git a/.server-changes/dev-cli-disconnect-md b/.server-changes/dev-cli-disconnect-md new file mode 100644 index 00000000000..a0790d70765 --- /dev/null +++ b/.server-changes/dev-cli-disconnect-md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Added `/engine/v1/dev/disconnect` endpoint to auto-cancel runs when the CLI disconnects. Maximum of 500 runs can be cancelled. Uses the bulk action system when there are more than 25 runs to cancel. \ No newline at end of file diff --git a/apps/webapp/app/routes/engine.v1.dev.disconnect.ts b/apps/webapp/app/routes/engine.v1.dev.disconnect.ts new file mode 100644 index 00000000000..0cf92a53b70 --- /dev/null +++ b/apps/webapp/app/routes/engine.v1.dev.disconnect.ts @@ -0,0 +1,180 @@ +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 { 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 prisma.taskRun.findMany({ + where: { + id: { in: runIds }, + runtimeEnvironmentId: environmentId, + }, + select: { + id: true, + engine: true, + friendlyId: true, + status: true, + createdAt: true, + completedAt: true, + taskEventStore: true, + }, + }); + + 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 }; diff --git a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts index 98b6079c108..4ca558bc2b7 100644 --- a/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts +++ b/apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts @@ -138,11 +138,13 @@ export class BulkActionService extends BaseService { } // 2. Parse the params + const rawParams = group.params && typeof group.params === "object" ? group.params : {}; + const finalizeRun = "finalizeRun" in rawParams && (rawParams as any).finalizeRun === true; const filters = parseRunListInputOptions({ organizationId: group.project.organizationId, projectId: group.projectId, environmentId: group.environmentId, - ...(group.params && typeof group.params === "object" ? group.params : {}), + ...rawParams, }); const runsRepository = new RunsRepository({ @@ -199,6 +201,7 @@ export class BulkActionService extends BaseService { cancelService.call(run, { reason: `Bulk action ${group.friendlyId} cancelled run`, bulkActionId: bulkActionId, + finalizeRun, }) ); if (error) { diff --git a/apps/webapp/app/v3/services/cancelTaskRun.server.ts b/apps/webapp/app/v3/services/cancelTaskRun.server.ts index 0c27a0b957f..76db56535ae 100644 --- a/apps/webapp/app/v3/services/cancelTaskRun.server.ts +++ b/apps/webapp/app/v3/services/cancelTaskRun.server.ts @@ -8,6 +8,8 @@ export type CancelTaskRunServiceOptions = { cancelAttempts?: boolean; cancelledAt?: Date; bulkActionId?: string; + /** Skip PENDING_CANCEL and finalize immediately (use when the worker is known to be dead). */ + finalizeRun?: boolean; }; type CancelTaskRunServiceResult = { @@ -57,6 +59,7 @@ export class CancelTaskRunService extends BaseService { runId: taskRun.id, completedAt: options?.cancelledAt, reason: options?.reason, + finalizeRun: options?.finalizeRun, bulkActionId: options?.bulkActionId, tx: this._prisma, }); diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 2d10e756b5b..067d00a14e0 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -1436,35 +1436,39 @@ export class RunAttemptSystem { }); //if executing, we need to message the worker to cancel the run and put it into `PENDING_CANCEL` status + //unless finalizeRun is true (worker is known to be dead), in which case skip straight to FINISHED if ( isExecuting(latestSnapshot.executionStatus) || isPendingExecuting(latestSnapshot.executionStatus) ) { - const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { - run, - snapshot: { - executionStatus: "PENDING_CANCEL", - description: "Run was cancelled", - }, - previousSnapshotId: latestSnapshot.id, - environmentId: latestSnapshot.environmentId, - environmentType: latestSnapshot.environmentType, - projectId: latestSnapshot.projectId, - organizationId: latestSnapshot.organizationId, - workerId, - runnerId, - }); + if (!finalizeRun) { + const newSnapshot = await this.executionSnapshotSystem.createExecutionSnapshot(prisma, { + run, + snapshot: { + executionStatus: "PENDING_CANCEL", + description: "Run was cancelled", + }, + previousSnapshotId: latestSnapshot.id, + environmentId: latestSnapshot.environmentId, + environmentType: latestSnapshot.environmentType, + projectId: latestSnapshot.projectId, + organizationId: latestSnapshot.organizationId, + workerId, + runnerId, + }); - //the worker needs to be notified so it can kill the run and complete the attempt - await sendNotificationToWorker({ - runId, - snapshot: newSnapshot, - eventBus: this.$.eventBus, - }); - return { - alreadyFinished: false, - ...executionResultFromSnapshot(newSnapshot), - }; + //the worker needs to be notified so it can kill the run and complete the attempt + await sendNotificationToWorker({ + runId, + snapshot: newSnapshot, + eventBus: this.$.eventBus, + }); + return { + alreadyFinished: false, + ...executionResultFromSnapshot(newSnapshot), + }; + } + // finalizeRun is true — fall through to finish the run immediately } //not executing, so we will actually finish the run diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index e099260203f..693b48d992e 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -7,6 +7,8 @@ import { DevConfigResponseBody, DevDequeueRequestBody, DevDequeueResponseBody, + DevDisconnectRequestBody, + DevDisconnectResponseBody, EnvironmentVariableResponseBody, FailDeploymentRequestBody, FailDeploymentResponseBody, @@ -557,6 +559,7 @@ export class CliApiClient { heartbeatRun: this.devHeartbeatRun.bind(this), startRunAttempt: this.devStartRunAttempt.bind(this), completeRunAttempt: this.devCompleteRunAttempt.bind(this), + disconnect: this.devDisconnect.bind(this), setEngineURL: this.setEngineURL.bind(this), } as const; } @@ -681,6 +684,23 @@ export class CliApiClient { return eventSource; } + private async devDisconnect( + body: DevDisconnectRequestBody + ): Promise> { + if (!this.accessToken) { + throw new Error("devDisconnect: No access token"); + } + + return wrapZodFetch(DevDisconnectResponseBody, `${this.engineURL}/engine/v1/dev/disconnect`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + } + private async devDequeue( body: DevDequeueRequestBody ): Promise> { diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 67da7e59458..70a884283fa 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -1,3 +1,7 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { setTimeout as awaitTimeout } from "node:timers/promises"; import { BuildManifest, @@ -71,6 +75,11 @@ class DevSupervisor implements WorkerRuntime { private runLimiter?: ReturnType; private taskRunProcessPool?: TaskRunProcessPool; + /** Detached watchdog process that cancels runs if the CLI is killed */ + private watchdogProcess?: ChildProcess; + private activeRunsPath?: string; + private watchdogPidPath?: string; + constructor(public readonly options: WorkerRuntimeOptions) {} async init(): Promise { @@ -138,25 +147,39 @@ class DevSupervisor implements WorkerRuntime { //start an SSE connection for presence this.disconnectPresence = await this.#startPresenceConnection(); - // Handle SIGTERM to gracefully stop all run controllers + // Handle SIGTERM/SIGINT to gracefully stop all run controllers process.on("SIGTERM", this.#handleSigterm); + process.on("SIGINT", this.#handleSigterm); + + // Spawn detached watchdog to cancel runs if CLI is killed (e.g. pnpm SIGKILL) + this.#spawnWatchdog(); //start dequeuing await this.#dequeueRuns(); } #handleSigterm = async () => { - logger.debug("[DevSupervisor] Received SIGTERM, stopping all run controllers"); + logger.debug("[DevSupervisor] Received SIGTERM/SIGINT, stopping all run controllers"); - const stopPromises = Array.from(this.runControllers.values()).map((controller) => - controller.stop() - ); + await this.shutdown(); - await Promise.allSettled(stopPromises); + // Must exit explicitly since registering a custom SIGINT handler + // overrides Node's default process termination behavior. + process.exit(0); }; async shutdown(): Promise { process.off("SIGTERM", this.#handleSigterm); + process.off("SIGINT", this.#handleSigterm); + + // Stop all local run controllers first so active-runs.json is up-to-date + const stopPromises = Array.from(this.runControllers.values()).map((controller) => + controller.stop() + ); + await Promise.allSettled(stopPromises); + + // Kill watchdog on clean shutdown — no disconnect needed since runs are stopped locally + this.#killWatchdog(); this.disconnectPresence?.(); try { @@ -177,6 +200,107 @@ class DevSupervisor implements WorkerRuntime { } } + #spawnWatchdog() { + const triggerDir = join(this.options.config.workingDir, ".trigger"); + if (!existsSync(triggerDir)) { + mkdirSync(triggerDir, { recursive: true }); + } + + this.activeRunsPath = join(triggerDir, "active-runs.json"); + this.watchdogPidPath = join(triggerDir, "watchdog.pid"); + + // Write empty active-runs file + this.#updateActiveRunsFile(); + + // Resolve the compiled watchdog script path relative to this file + const thisDir = fileURLToPath(new URL(".", import.meta.url)); + const watchdogScript = join(thisDir, "devWatchdog.js"); + + if (!existsSync(watchdogScript)) { + logger.debug("[DevSupervisor] Watchdog script not found, skipping", { watchdogScript }); + return; + } + + try { + this.watchdogProcess = spawn(process.execPath, [watchdogScript], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + WATCHDOG_PARENT_PID: process.pid.toString(), + WATCHDOG_API_URL: this.config?.engineUrl ?? this.options.client.apiURL, + WATCHDOG_API_KEY: this.options.client.accessToken ?? "", + WATCHDOG_ACTIVE_RUNS: this.activeRunsPath, + WATCHDOG_PID_FILE: this.watchdogPidPath, + }, + }); + + this.watchdogProcess.unref(); + + logger.debug("[DevSupervisor] Spawned watchdog", { + watchdogPid: this.watchdogProcess.pid, + parentPid: process.pid, + }); + } catch (error) { + logger.debug("[DevSupervisor] Failed to spawn watchdog", { error }); + } + } + + #killWatchdog() { + const knownPid = this.watchdogProcess?.pid; + + if (knownPid) { + try { + process.kill(knownPid, "SIGTERM"); + } catch { + // Already dead + } + this.watchdogProcess = undefined; + } + + // Fallback: try via PID file, but only if the PID matches our spawned watchdog + // to avoid killing an unrelated process that reused a stale PID + if (this.watchdogPidPath) { + try { + const content = readFileSync(this.watchdogPidPath, "utf8"); + const prefix = "trigger-watchdog:"; + if (content.startsWith(prefix)) { + const pid = parseInt(content.slice(prefix.length), 10); + if (pid && (!knownPid || pid === knownPid)) { + process.kill(pid, "SIGTERM"); + } + } + } catch { + // Already dead or no file + } + } + + // Clean up files + try { + if (this.activeRunsPath) unlinkSync(this.activeRunsPath); + } catch {} + try { + if (this.watchdogPidPath) unlinkSync(this.watchdogPidPath); + } catch {} + } + + #updateActiveRunsFile() { + if (!this.activeRunsPath) return; + + try { + const data = { + parentPid: process.pid, + runFriendlyIds: Array.from(this.runControllers.keys()), + }; + // Atomic write: write to temp file then rename to avoid corrupt reads + const tmpPath = this.activeRunsPath + ".tmp"; + writeFileSync(tmpPath, JSON.stringify(data)); + renameSync(tmpPath, this.activeRunsPath); + } catch (error) { + logger.debug("[DevSupervisor] Failed to update active-runs file", { error }); + } + } + async initializeWorker( manifest: BuildManifest, metafile: Metafile, @@ -386,6 +510,7 @@ class DevSupervisor implements WorkerRuntime { //stop the run controller, and remove it runController?.stop(); this.runControllers.delete(message.run.friendlyId); + this.#updateActiveRunsFile(); this.#unsubscribeFromRunNotifications(message.run.friendlyId); //stop the worker if it is deprecated and there are no more runs @@ -402,6 +527,7 @@ class DevSupervisor implements WorkerRuntime { }); this.runControllers.set(message.run.friendlyId, runController); + this.#updateActiveRunsFile(); if (this.runLimiter) { this.runLimiter(() => runController.start(message)).then(() => { diff --git a/packages/cli-v3/src/dev/devWatchdog.ts b/packages/cli-v3/src/dev/devWatchdog.ts new file mode 100644 index 00000000000..b3ccb782189 --- /dev/null +++ b/packages/cli-v3/src/dev/devWatchdog.ts @@ -0,0 +1,174 @@ +/** + * Dev Watchdog — a detached process that cancels in-flight runs when the dev CLI exits. + * + * Spawned by the dev CLI with `detached: true, stdio: "ignore", unref()`. + * Survives when pnpm sends SIGKILL to the CLI process tree. + * + * Lifecycle: + * 1. CLI spawns this script, passing config via env vars + * 2. Writes PID file for single-instance guarantee + * 3. Polls parent PID to detect when the CLI exits + * 4. On parent death: reads active-runs file → calls disconnect endpoint → exits + * + * Environment variables: + * WATCHDOG_PARENT_PID - The PID of the parent dev CLI process + * WATCHDOG_API_URL - The Trigger.dev API/engine URL + * WATCHDOG_API_KEY - The API key for authentication + * WATCHDOG_ACTIVE_RUNS - Path to the active-runs JSON file + * WATCHDOG_PID_FILE - Path to write the watchdog PID file + */ + +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +const POLL_INTERVAL_MS = 1000; + +// Safety timeout: if the watchdog has been running for 24 hours, exit regardless. +// Prevents zombie watchdogs from PID reuse scenarios. +const MAX_LIFETIME_MS = 24 * 60 * 60 * 1000; + +const parentPid = parseInt(process.env.WATCHDOG_PARENT_PID!, 10); +const apiUrl = process.env.WATCHDOG_API_URL!; +const apiKey = process.env.WATCHDOG_API_KEY!; +const activeRunsPath = process.env.WATCHDOG_ACTIVE_RUNS!; +const pidFilePath = process.env.WATCHDOG_PID_FILE!; + +if (!parentPid || !apiUrl || !apiKey || !activeRunsPath || !pidFilePath) { + process.exit(1); +} + +// Ensure directory exists +const dir = dirname(pidFilePath); +if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); +} + +const PID_FILE_PREFIX = "trigger-watchdog:"; + +// Single instance: kill any existing watchdog +try { + const pidFileContent = readFileSync(pidFilePath, "utf8"); + if (pidFileContent.startsWith(PID_FILE_PREFIX)) { + const existingPid = parseInt(pidFileContent.slice(PID_FILE_PREFIX.length), 10); + if (existingPid && existingPid !== process.pid) { + try { + process.kill(existingPid, 0); // Check if alive + process.kill(existingPid, "SIGTERM"); // Kill it + } catch { + // Already dead + } + } + } +} catch { + // No PID file or invalid format +} + +// Write our PID with prefix so we can verify ownership later +writeFileSync(pidFilePath, `${PID_FILE_PREFIX}${process.pid}`); + +function cleanup() { + try { + unlinkSync(pidFilePath); + } catch {} + try { + unlinkSync(activeRunsPath); + } catch {} +} + +function isParentAlive(): boolean { + try { + process.kill(parentPid, 0); + return true; + } catch { + return false; + } +} + +function readActiveRuns(): string[] { + try { + const data = JSON.parse(readFileSync(activeRunsPath, "utf8")); + return data.runFriendlyIds ?? []; + } catch { + return []; + } +} + +async function callDisconnect(runFriendlyIds: string[]): Promise { + const response = await fetch(`${apiUrl}/engine/v1/dev/disconnect`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ runFriendlyIds }), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`Disconnect failed with status ${response.status}`); + } +} + +const MAX_DISCONNECT_ATTEMPTS = 5; +const INITIAL_BACKOFF_MS = 500; + +async function onParentDied(): Promise { + const runFriendlyIds = readActiveRuns(); + + if (runFriendlyIds.length > 0) { + for (let attempt = 0; attempt < MAX_DISCONNECT_ATTEMPTS; attempt++) { + try { + await callDisconnect(runFriendlyIds); + break; + } catch { + if (attempt < MAX_DISCONNECT_ATTEMPTS - 1) { + const backoff = INITIAL_BACKOFF_MS * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, backoff)); + } + // Final attempt failed — runs will eventually time out via heartbeat + } + } + } + + cleanup(); + process.exit(0); +} + +// Guard against overlapping async callbacks +let checking = false; + +const interval = setInterval(async () => { + if (checking) return; + checking = true; + + try { + if (!isParentAlive()) { + clearInterval(interval); + clearTimeout(lifetimeTimeout); + await onParentDied(); + } + } finally { + checking = false; + } +}, POLL_INTERVAL_MS); + +// Safety timeout: exit after MAX_LIFETIME_MS to prevent zombie watchdogs +const lifetimeTimeout = setTimeout(() => { + clearInterval(interval); + cleanup(); + process.exit(0); +}, MAX_LIFETIME_MS); + +// Unref the timeout so it doesn't keep the process alive if the interval is cleared +lifetimeTimeout.unref(); + +// Clean exit on any termination signal +function handleSignal() { + clearInterval(interval); + clearTimeout(lifetimeTimeout); + cleanup(); + process.exit(0); +} + +process.on("SIGTERM", handleSignal); +process.on("SIGINT", handleSignal); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index d42e6158096..dc72f155bdc 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -838,6 +838,17 @@ export const DevDequeueResponseBody = z.object({ }); export type DevDequeueResponseBody = z.infer; +export const DevDisconnectRequestBody = z.object({ + runFriendlyIds: z.string().array(), +}); +export type DevDisconnectRequestBody = z.infer; + +export const DevDisconnectResponseBody = z.object({ + cancelled: z.number(), + bulkActionId: z.string().optional(), +}); +export type DevDisconnectResponseBody = z.infer; + export type CreateUploadPayloadUrlResponseBody = z.infer; export const ReplayRunResponse = z.object({