Merge remote-tracking branch 'origin/main' into feat/compute-workload-manager

This commit is contained in:
nicktrn
2026-03-10 12:52:57 +00:00
36 changed files with 830 additions and 156 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@trigger.dev/build": patch
---
Add syncSupabaseEnvVars to pull database connection strings and save them as trigger.dev environment variables
-11
View File
@@ -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
+6
View File
@@ -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.
@@ -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.
@@ -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) => {
@@ -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 = (
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName,
iconSpacing
<div className={cn("relative", "flex w-full items-center")}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName,
iconSpacing,
showSpinner && "invisible"
)}
>
{LeadingIcon && (
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
/>
)}
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon && (
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
{showSpinner && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-3.5" color="white" />
</span>
)}
>
{LeadingIcon && (
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
/>
)}
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon && (
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
</div>
);
@@ -298,6 +327,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
const innerRef = useRef<HTMLButtonElement>(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<HTMLButtonElement, ButtonPropsType>(
e.stopPropagation();
}
},
disabled: disabled || !props.shortcut,
disabled: isDisabled || !props.shortcut,
});
return (
<button
className={cn("group/button outline-none focus-custom", props.fullWidth ? "w-full" : "")}
type={type}
disabled={disabled}
disabled={isDisabled}
onClick={onClick}
name={props.name}
value={props.value}
@@ -22,7 +22,7 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Select, SelectItem } from "~/components/primitives/Select";
import { ButtonSpinner } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -169,6 +169,8 @@ const schema = z.object({
technologiesOther: z.string().optional(),
goals: z.string().optional(),
goalsOther: z.string().optional(),
workingOnPositions: z.string().optional(),
goalsPositions: z.string().optional(),
});
export const action: ActionFunction = async ({ request, params }) => {
@@ -200,10 +202,25 @@ export const action: ActionFunction = async ({ request, params }) => {
}
}
const numberArraySchema = z.array(z.number());
function safeParseNumberArray(value: string | undefined): number[] | undefined {
if (!value) return undefined;
try {
const result = numberArraySchema.safeParse(JSON.parse(value));
return result.success && result.data.length > 0 ? result.data : undefined;
} catch {
return undefined;
}
}
const onboardingData: Record<string, Prisma.InputJsonValue> = {};
const workingOn = safeParseStringArray(submission.value.workingOn);
if (workingOn) onboardingData.workingOn = workingOn;
if (workingOn) {
onboardingData.workingOn = workingOn;
const workingOnPositions = safeParseNumberArray(submission.value.workingOnPositions);
if (workingOnPositions) onboardingData.workingOnPositions = workingOnPositions;
}
if (submission.value.workingOnOther) {
onboardingData.workingOnOther = submission.value.workingOnOther;
@@ -216,7 +233,11 @@ export const action: ActionFunction = async ({ request, params }) => {
if (technologiesOther) onboardingData.technologiesOther = technologiesOther;
const goals = safeParseStringArray(submission.value.goals);
if (goals) onboardingData.goals = goals;
if (goals) {
onboardingData.goals = goals;
const goalsPositions = safeParseNumberArray(submission.value.goalsPositions);
if (goalsPositions) onboardingData.goalsPositions = goalsPositions;
}
if (submission.value.goalsOther) {
onboardingData.goalsOther = submission.value.goalsOther;
@@ -376,6 +397,13 @@ export default function Page() {
<InputGroup>
<Label>What are you working on?</Label>
<input type="hidden" name="workingOn" value={JSON.stringify(selectedWorkingOn)} />
<input
type="hidden"
name="workingOnPositions"
value={JSON.stringify(
selectedWorkingOn.map((v) => shuffledWorkingOn.indexOf(v) + 1)
)}
/>
<MultiSelectField
value={selectedWorkingOn}
setValue={setSelectedWorkingOn}
@@ -421,6 +449,13 @@ export default function Page() {
<InputGroup>
<Label>What are you trying to do with Trigger.dev?</Label>
<input type="hidden" name="goals" value={JSON.stringify(selectedGoals)} />
<input
type="hidden"
name="goalsPositions"
value={JSON.stringify(
selectedGoals.map((v) => shuffledGoals.indexOf(v) + 1)
)}
/>
<MultiSelectField
value={selectedGoals}
setValue={setSelectedGoals}
@@ -445,13 +480,8 @@ export default function Page() {
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isLoading}
TrailingIcon={isLoading ? ButtonSpinner : undefined}
>
{isLoading ? "Creating…" : "Create"}
<Button type="submit" variant={"primary/small"} isLoading={isLoading}>
Create
</Button>
}
cancelButton={
@@ -220,7 +220,7 @@ export default function NewOrganizationPage() {
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} disabled={isLoading}>
<Button type="submit" variant={"primary/small"} isLoading={isLoading}>
Create
</Button>
}
@@ -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<string>(user.email ?? "");
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting" || navigation.state === "loading";
const [selectedReferralSource, setSelectedReferralSource] = useState<string | undefined>();
const [selectedRole, setSelectedRole] = useState<string>("");
@@ -317,6 +327,15 @@ export default function Page() {
name="referralSource"
value={selectedReferralSource ?? ""}
/>
<input
type="hidden"
name="referralSourcePosition"
value={
selectedReferralSource
? shuffledReferralSources.indexOf(selectedReferralSource) + 1
: ""
}
/>
<RadioGroup
value={selectedReferralSource}
onValueChange={setSelectedReferralSource}
@@ -348,6 +367,11 @@ export default function Page() {
<InputGroup className="mt-1">
<Label id="role-label">What role fits you best?</Label>
<input type="hidden" name="role" value={selectedRole} />
<input
type="hidden"
name="rolePosition"
value={selectedRole ? shuffledRoles.indexOf(selectedRole) + 1 : ""}
/>
<Select<string, string>
value={selectedRole}
setValue={setSelectedRole}
@@ -384,7 +408,12 @@ export default function Page() {
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} TrailingIcon={ArrowRightIcon}>
<Button
type="submit"
variant={"primary/small"}
TrailingIcon={ArrowRightIcon}
isLoading={isSubmitting}
>
Continue
</Button>
}
@@ -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<number> {
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<string> {
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 };
@@ -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) {
@@ -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,
});
@@ -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
+8
View File
@@ -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
+2 -2
View File
@@ -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",
+10
View File
@@ -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
+4 -4
View File
@@ -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",
+20
View File
@@ -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<ApiResult<DevDisconnectResponseBody>> {
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<ApiResult<DevDequeueResponseBody>> {
+132 -6
View File
@@ -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<typeof pLimit>;
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<void> {
@@ -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<void> {
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(() => {
+174
View File
@@ -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<void> {
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<void> {
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);
+6
View File
@@ -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
+1 -1
View File
@@ -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": {
+11
View File
@@ -838,6 +838,17 @@ export const DevDequeueResponseBody = z.object({
});
export type DevDequeueResponseBody = z.infer<typeof DevDequeueResponseBody>;
export const DevDisconnectRequestBody = z.object({
runFriendlyIds: z.string().array(),
});
export type DevDisconnectRequestBody = z.infer<typeof DevDisconnectRequestBody>;
export const DevDisconnectResponseBody = z.object({
cancelled: z.number(),
bulkActionId: z.string().optional(),
});
export type DevDisconnectResponseBody = z.infer<typeof DevDisconnectResponseBody>;
export type CreateUploadPayloadUrlResponseBody = z.infer<typeof CreateUploadPayloadUrlResponseBody>;
export const ReplayRunResponse = z.object({
+9
View File
@@ -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
+6 -6
View File
@@ -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"
+7
View File
@@ -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
+2 -2
View File
@@ -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": {
+7
View File
@@ -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
+2 -2
View File
@@ -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",
+7
View File
@@ -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
+3 -3
View File
@@ -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": "*",
+7
View File
@@ -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
+1 -1
View File
@@ -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": {
+7
View File
@@ -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
+2 -2
View File
@@ -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",
+12 -12
View File
@@ -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