diff --git a/.changeset/warm-falcons-joke.md b/.changeset/warm-falcons-joke.md deleted file mode 100644 index 845473c1a..000000000 --- a/.changeset/warm-falcons-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/build": patch ---- - -Add syncSupabaseEnvVars to pull database connection strings and save them as trigger.dev environment variables diff --git a/.server-changes/add-errors-page.md b/.server-changes/add-errors-page.md deleted file mode 100644 index cdb2a1b6c..000000000 --- a/.server-changes/add-errors-page.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -area: webapp -type: feature ---- - -A new Errors page for viewing and tracking errors that cause runs to fail - -- Errors are grouped using error fingerprinting -- View top errors for a time period, filter by task, or search the text -- View occurrences over time -- View all the runs for an error and bulk replay them diff --git a/.server-changes/dev-cli-disconnect-md b/.server-changes/dev-cli-disconnect-md new file mode 100644 index 000000000..a0790d707 --- /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/.server-changes/test-page-sidebar-tabs.md b/.server-changes/test-page-sidebar-tabs.md deleted file mode 100644 index 1803f2c2a..000000000 --- a/.server-changes/test-page-sidebar-tabs.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Add sidebar tabs (Options, AI, Schema) to the Test page for schemaTask payload generation and schema viewing. diff --git a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx index 3d822e4b6..9c9f9a6b2 100644 --- a/apps/webapp/app/components/onboarding/TechnologyPicker.tsx +++ b/apps/webapp/app/components/onboarding/TechnologyPicker.tsx @@ -210,11 +210,22 @@ export function TechnologyPicker({ const addCustomValue = useCallback(() => { const trimmed = otherInputValue.trim(); - if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) { + if (!trimmed) return; + + const matchedOption = TECHNOLOGY_OPTIONS.find( + (opt) => opt.toLowerCase() === trimmed.toLowerCase() + ); + + if (matchedOption) { + if (!value.includes(matchedOption)) { + onChange([...value, matchedOption]); + } + } else if (!customValues.includes(trimmed) && !value.includes(trimmed)) { onCustomValuesChange([...customValues, trimmed]); - setOtherInputValue(""); } - }, [otherInputValue, customValues, onCustomValuesChange, value]); + + setOtherInputValue(""); + }, [otherInputValue, customValues, onCustomValuesChange, value, onChange]); const handleOtherKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 5c52a5d95..8ba196b5d 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -1,10 +1,18 @@ import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react"; -import React, { forwardRef, type ReactNode, useImperativeHandle, useRef } from "react"; +import React, { + forwardRef, + type ReactNode, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; import { Icon, type RenderIcon } from "./Icon"; +import { Spinner } from "./Spinner"; const sizes = { small: { @@ -180,6 +188,7 @@ export type ButtonContentPropsType = { tooltip?: ReactNode; iconSpacing?: string; hideShortcutKey?: boolean; + isLoading?: boolean; }; export function ButtonContent(props: ButtonContentPropsType) { @@ -196,7 +205,19 @@ export function ButtonContent(props: ButtonContentPropsType) { tooltip, iconSpacing, hideShortcutKey, + isLoading, } = props; + + const [showSpinner, setShowSpinner] = useState(false); + useEffect(() => { + if (!isLoading) { + setShowSpinner(false); + return; + } + const timer = setTimeout(() => setShowSpinner(true), 200); + return () => clearTimeout(timer); + }, [isLoading]); + const variation = allVariants.variant[props.variant]; const btnClassName = cn(allVariants.$all, variation.button); @@ -217,56 +238,64 @@ export function ButtonContent(props: ButtonContentPropsType) { const buttonContent = (
-
+
+ {LeadingIcon && ( + + )} + + {text && + (typeof text === "string" ? ( + + {text} + + ) : ( + <>{text} + ))} + + {shortcut && + !tooltip && + props.shortcutPosition === "before-trailing-icon" && + renderShortcutKey()} + + {TrailingIcon && ( + + )} + + {shortcut && + !tooltip && + (!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && + renderShortcutKey()} +
+ {showSpinner && ( + + + )} - > - {LeadingIcon && ( - - )} - - {text && - (typeof text === "string" ? ( - - {text} - - ) : ( - <>{text} - ))} - - {shortcut && - !tooltip && - props.shortcutPosition === "before-trailing-icon" && - renderShortcutKey()} - - {TrailingIcon && ( - - )} - - {shortcut && - !tooltip && - (!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && - renderShortcutKey()}
); @@ -298,6 +327,8 @@ export const Button = forwardRef( const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement); + const isDisabled = disabled || props.isLoading; + useShortcutKeys({ shortcut: props.shortcut, action: (e) => { @@ -307,14 +338,14 @@ export const Button = forwardRef( e.stopPropagation(); } }, - disabled: disabled || !props.shortcut, + disabled: isDisabled || !props.shortcut, }); return ( } cancelButton={ diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index b1d8fd83a..fb072518c 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -220,7 +220,7 @@ export default function NewOrganizationPage() { + } diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index fad485e6d..ed8681865 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -4,7 +4,7 @@ import { ArrowRightIcon, EnvelopeIcon, UserGroupIcon, UserIcon } from "@heroicon import { HandRaisedIcon } from "@heroicons/react/24/solid"; import { RadioGroup } from "@radix-ui/react-radio-group"; import { json, type ActionFunction } from "@remix-run/node"; -import { Form, useActionData } from "@remix-run/react"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; import { motion } from "framer-motion"; import { forwardRef, useEffect, useState } from "react"; import { z } from "zod"; @@ -99,6 +99,8 @@ function createSchema( referralSourceOther: z.string().optional(), role: z.string().optional(), roleOther: z.string().optional(), + referralSourcePosition: z.coerce.number().optional(), + rolePosition: z.coerce.number().optional(), }) .refine((value) => value.email === value.confirmEmail, { message: "Emails must match", @@ -141,6 +143,9 @@ export const action: ActionFunction = async ({ request }) => { if (submission.value.referralSource) { onboardingData.referralSource = submission.value.referralSource; + if (submission.value.referralSourcePosition) { + onboardingData.referralSourcePosition = String(submission.value.referralSourcePosition); + } if (submission.value.referralSource === "Other" && submission.value.referralSourceOther) { onboardingData.referralSourceOther = submission.value.referralSourceOther; } @@ -148,6 +153,9 @@ export const action: ActionFunction = async ({ request }) => { if (submission.value.role) { onboardingData.role = submission.value.role; + if (submission.value.rolePosition) { + onboardingData.rolePosition = String(submission.value.rolePosition); + } if (submission.value.role === "Other" && submission.value.roleOther) { onboardingData.roleOther = submission.value.roleOther; } @@ -201,6 +209,8 @@ export default function Page() { const lastSubmission = useActionData(); const [enteredEmail, setEnteredEmail] = useState(user.email ?? ""); const { isManagedCloud } = useFeatures(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting" || navigation.state === "loading"; const [selectedReferralSource, setSelectedReferralSource] = useState(); const [selectedRole, setSelectedRole] = useState(""); @@ -317,6 +327,15 @@ export default function Page() { name="referralSource" value={selectedReferralSource ?? ""} /> + + value={selectedRole} setValue={setSelectedRole} @@ -384,7 +408,12 @@ export default function Page() { + } 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 000000000..0cf92a53b --- /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 98b6079c1..4ca558bc2 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 0c27a0b95..76db56535 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 2d10e756b..067d00a14 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/build/CHANGELOG.md b/packages/build/CHANGELOG.md index 1da6d4c29..9e99c1f63 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,13 @@ # @trigger.dev/build +## 4.4.3 + +### Patch Changes + +- Add syncSupabaseEnvVars to pull database connection strings and save them as trigger.dev environment variables ([#3152](https://github.com/triggerdotdev/trigger.dev/pull/3152)) +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index 76cb94cdd..cbde207ae 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/build", - "version": "4.4.2", + "version": "4.4.3", "description": "trigger.dev build extensions", "license": "MIT", "publishConfig": { @@ -78,7 +78,7 @@ }, "dependencies": { "@prisma/config": "^6.10.0", - "@trigger.dev/core": "workspace:4.4.2", + "@trigger.dev/core": "workspace:4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md index b0304d2e8..709c20918 100644 --- a/packages/cli-v3/CHANGELOG.md +++ b/packages/cli-v3/CHANGELOG.md @@ -1,5 +1,15 @@ # trigger.dev +## 4.4.3 + +### Patch Changes + +- Auto-cancel in-flight dev runs when the CLI exits, using a detached watchdog process that survives pnpm SIGKILL ([#3191](https://github.com/triggerdotdev/trigger.dev/pull/3191)) +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + - `@trigger.dev/build@4.4.3` + - `@trigger.dev/schema-to-json@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 82257f67a..e5de14b9f 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -1,6 +1,6 @@ { "name": "trigger.dev", - "version": "4.4.2", + "version": "4.4.3", "description": "A Command-Line Interface for Trigger.dev projects", "type": "module", "license": "MIT", @@ -93,9 +93,9 @@ "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "^0.22.5", - "@trigger.dev/build": "workspace:4.4.2", - "@trigger.dev/core": "workspace:4.4.2", - "@trigger.dev/schema-to-json": "workspace:4.4.2", + "@trigger.dev/build": "workspace:4.4.3", + "@trigger.dev/core": "workspace:4.4.3", + "@trigger.dev/schema-to-json": "workspace:4.4.3", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index e09926020..693b48d99 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 67da7e594..70a884283 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 000000000..b3ccb7821 --- /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/CHANGELOG.md b/packages/core/CHANGELOG.md index f8b276c29..db5412c20 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # internal-platform +## 4.4.3 + +### Patch Changes + +- Auto-cancel in-flight dev runs when the CLI exits, using a detached watchdog process that survives pnpm SIGKILL ([#3191](https://github.com/triggerdotdev/trigger.dev/pull/3191)) + ## 4.4.2 ## 4.4.1 diff --git a/packages/core/package.json b/packages/core/package.json index 3bb7c7e00..1ecf044bf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/core", - "version": "4.4.2", + "version": "4.4.3", "description": "Core code used across the Trigger.dev SDK and platform", "license": "MIT", "publishConfig": { diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index d42e61580..dc72f155b 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({ diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md index 75a6bae78..446e66012 100644 --- a/packages/python/CHANGELOG.md +++ b/packages/python/CHANGELOG.md @@ -1,5 +1,14 @@ # @trigger.dev/python +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + - `@trigger.dev/build@4.4.3` + - `@trigger.dev/sdk@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/python/package.json b/packages/python/package.json index cea36142e..c8c7dfcfe 100644 --- a/packages/python/package.json +++ b/packages/python/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/python", - "version": "4.4.2", + "version": "4.4.3", "description": "Python runtime and build extension for Trigger.dev", "license": "MIT", "publishConfig": { @@ -45,7 +45,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:4.4.2", + "@trigger.dev/core": "workspace:4.4.3", "tinyexec": "^0.3.2" }, "devDependencies": { @@ -56,12 +56,12 @@ "tsx": "4.17.0", "esbuild": "^0.23.0", "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:4.4.2", - "@trigger.dev/sdk": "workspace:4.4.2" + "@trigger.dev/build": "workspace:4.4.3", + "@trigger.dev/sdk": "workspace:4.4.3" }, "peerDependencies": { - "@trigger.dev/sdk": "workspace:^4.4.2", - "@trigger.dev/build": "workspace:^4.4.2" + "@trigger.dev/sdk": "workspace:^4.4.3", + "@trigger.dev/build": "workspace:^4.4.3" }, "engines": { "node": ">=18.20.0" diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md index a1d6b98bb..a0f9233c1 100644 --- a/packages/react-hooks/CHANGELOG.md +++ b/packages/react-hooks/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/react-hooks +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index 1ab688b93..239c7d772 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/react-hooks", - "version": "4.4.2", + "version": "4.4.3", "description": "trigger.dev react hooks", "license": "MIT", "publishConfig": { @@ -37,7 +37,7 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.4.2", + "@trigger.dev/core": "workspace:^4.4.3", "swr": "^2.2.5" }, "devDependencies": { diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md index 3be71e59d..eee31aa62 100644 --- a/packages/redis-worker/CHANGELOG.md +++ b/packages/redis-worker/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/redis-worker +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json index 6e8ad6a47..fef723433 100644 --- a/packages/redis-worker/package.json +++ b/packages/redis-worker/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/redis-worker", - "version": "4.4.2", + "version": "4.4.3", "description": "Redis worker for trigger.dev", "license": "MIT", "publishConfig": { @@ -23,7 +23,7 @@ "test": "vitest --sequence.concurrent=false --no-file-parallelism" }, "dependencies": { - "@trigger.dev/core": "workspace:4.4.2", + "@trigger.dev/core": "workspace:4.4.3", "lodash.omit": "^4.5.0", "nanoid": "^5.0.7", "p-limit": "^6.2.0", diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md index 247f0c08b..fe0155463 100644 --- a/packages/rsc/CHANGELOG.md +++ b/packages/rsc/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/rsc +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/rsc/package.json b/packages/rsc/package.json index a3dd0788a..b887f695b 100644 --- a/packages/rsc/package.json +++ b/packages/rsc/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/rsc", - "version": "4.4.2", + "version": "4.4.3", "description": "trigger.dev rsc", "license": "MIT", "publishConfig": { @@ -37,14 +37,14 @@ "check-exports": "attw --pack ." }, "dependencies": { - "@trigger.dev/core": "workspace:^4.4.2", + "@trigger.dev/core": "workspace:^4.4.3", "mlly": "^1.7.1", "react": "19.0.0-rc.1", "react-dom": "19.0.0-rc.1" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.4", - "@trigger.dev/build": "workspace:^4.4.2", + "@trigger.dev/build": "workspace:^4.4.3", "@types/node": "^20.14.14", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/schema-to-json/CHANGELOG.md b/packages/schema-to-json/CHANGELOG.md index 957145e2b..e24fa24b0 100644 --- a/packages/schema-to-json/CHANGELOG.md +++ b/packages/schema-to-json/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/schema-to-json +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json index b283ebd2c..e2963c1d7 100644 --- a/packages/schema-to-json/package.json +++ b/packages/schema-to-json/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/schema-to-json", - "version": "4.4.2", + "version": "4.4.3", "description": "Convert various schema validation libraries to JSON Schema", "license": "MIT", "publishConfig": { diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md index f7b1903fd..dbccf319f 100644 --- a/packages/trigger-sdk/CHANGELOG.md +++ b/packages/trigger-sdk/CHANGELOG.md @@ -1,5 +1,12 @@ # @trigger.dev/sdk +## 4.4.3 + +### Patch Changes + +- Updated dependencies: + - `@trigger.dev/core@4.4.3` + ## 4.4.2 ### Patch Changes diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index c6c7b04cc..e1ff05c4d 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@trigger.dev/sdk", - "version": "4.4.2", + "version": "4.4.3", "description": "trigger.dev Node.JS SDK", "license": "MIT", "publishConfig": { @@ -52,7 +52,7 @@ "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", - "@trigger.dev/core": "workspace:4.4.2", + "@trigger.dev/core": "workspace:4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15afd915c..83e283083 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1382,7 +1382,7 @@ importers: specifier: ^6.10.0 version: 6.19.0(magicast@0.3.5) '@trigger.dev/core': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../core mlly: specifier: ^1.7.1 @@ -1458,13 +1458,13 @@ importers: specifier: ^0.22.5 version: 0.22.5(supports-color@10.0.0) '@trigger.dev/build': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../build '@trigger.dev/core': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../core '@trigger.dev/schema-to-json': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 @@ -1832,7 +1832,7 @@ importers: packages/python: dependencies: '@trigger.dev/core': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../core tinyexec: specifier: ^0.3.2 @@ -1842,10 +1842,10 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../build '@trigger.dev/sdk': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../trigger-sdk '@types/node': specifier: 20.14.14 @@ -1869,7 +1869,7 @@ importers: packages/react-hooks: dependencies: '@trigger.dev/core': - specifier: workspace:^4.4.2 + specifier: workspace:^4.4.3 version: link:../core react: specifier: ^18.0 || ^19.0 || ^19.0.0-rc @@ -1903,7 +1903,7 @@ importers: packages/redis-worker: dependencies: '@trigger.dev/core': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../core cron-parser: specifier: ^4.9.0 @@ -1952,7 +1952,7 @@ importers: packages/rsc: dependencies: '@trigger.dev/core': - specifier: workspace:^4.4.2 + specifier: workspace:^4.4.3 version: link:../core mlly: specifier: ^1.7.1 @@ -1968,7 +1968,7 @@ importers: specifier: ^0.15.4 version: 0.15.4 '@trigger.dev/build': - specifier: workspace:^4.4.2 + specifier: workspace:^4.4.3 version: link:../build '@types/node': specifier: 20.14.14 @@ -2044,7 +2044,7 @@ importers: specifier: 1.36.0 version: 1.36.0 '@trigger.dev/core': - specifier: workspace:4.4.2 + specifier: workspace:4.4.3 version: link:../core chalk: specifier: ^5.2.0