Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-dates-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/schema-to-json": patch
---

Convert Zod 4 `z.date()` fields to date-time strings in JSON Schema without weakening validation for other unsupported types. This prevents MCP tool discovery from failing when a tool input schema contains a date.
5 changes: 5 additions & 0 deletions .changeset/promote-environment-api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

Authenticate `trigger promote` with environment API keys supplied through `TRIGGER_ACCESS_TOKEN`. Environment API key commands now use the saved profile API URL when no explicit override is provided.
5 changes: 5 additions & 0 deletions .changeset/snapshot-route-zod-v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Fixes warm starts silently failing for deployments built with 4.6.0 to 4.6.3 in projects that resolve `zod` to a 3.x release. The runner could not parse the run handed to it by the warm-start service and exited, leaving the run waiting until the platform redrove it a few minutes later and started it cold. Redeploy to pick up the fix.
9 changes: 8 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"jsPlugins": [
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
"./oxlint-plugins/runops-residency.mjs",
"./oxlint-plugins/prisma-in-filter.mjs"
"./oxlint-plugins/prisma-in-filter.mjs",
"./oxlint-plugins/zod-imports.mjs"
],
"ignorePatterns": [
"**/dist/**",
Expand Down Expand Up @@ -158,6 +159,12 @@
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"files": ["packages/core/src/**/*.ts", "packages/core/src/**/*.tsx"],
"rules": {
"trigger-zod/require-versioned-import": "error"
}
},
{
"files": ["packages/react-hooks/src/**/*.ts", "packages/react-hooks/src/**/*.tsx"],
"rules": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,8 @@ export const loader = dashboardLoader(
const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS;
const directorySync = dsyncResult.isOk() ? dsyncResult.value : EMPTY_DIRECTORY_SYNC_STATUS;

// JIT can't grant Owner (reserved), and non-assignable/plan-gated roles
// are filtered out.
const assignable = new Set(assignableIds);
const jitRoles = allRoles.filter((r) => r.name !== "Owner" && assignable.has(r.id));
const jitRoles = allRoles.filter((r) => assignable.has(r.id));

return typedjson({
status,
Expand Down Expand Up @@ -810,7 +808,7 @@ function ActiveConnectionState({
/>
<SettingsRow
title="Default role for new users"
description="Assigned to users created by just-in-time provisioning. Owner can't be granted automatically."
description="Assigned to users created by just-in-time provisioning."
action={
<Select<string, Role>
value={draftJitRoleId}
Expand Down Expand Up @@ -1085,7 +1083,11 @@ function DirectorySyncSection({
setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v }))
}
items={[
{ id: NULL_ROLE_VALUE, name: "No access", description: "" },
{
id: NULL_ROLE_VALUE,
name: "Inherit",
description: "Uses the Default role for unmapped users",
},
...jitRoles,
]}
variant="secondary/small"
Expand All @@ -1094,7 +1096,7 @@ function DirectorySyncSection({
placement="bottom-end"
text={(v) =>
v === NULL_ROLE_VALUE
? "No access"
? "Inherit"
: (jitRoles.find((r) => r.id === v)?.name ?? "Select a role")
}
>
Expand Down
4 changes: 1 addition & 3 deletions apps/webapp/app/services/directorySyncEffects.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ async function applyEffect(effect: DirectorySyncEffect): Promise<string | null>
source: "directory_sync",
});

// Directory owns the role: overwrite even an existing member
// (ensureOrgMember only sets it on create).
if (effect.roleId) {
if (effect.roleId && effect.roleAuthoritative) {
const result = await rbac.setUserRole({
userId,
organizationId: effect.organizationId,
Expand Down
6 changes: 5 additions & 1 deletion apps/webapp/app/v3/accountsWebhookWorker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ function initializeWorker() {
data: payload.data,
});
if (result.isErr()) {
throw new Error(`account webhook processing failed: ${result.error}`);
const error = new Error(`account webhook processing failed: ${result.error}`);
if (result.error === "not_ready") {
Object.assign(error, { logLevel: "warn" as const });
}
throw error;
}
// Directory-sync events return membership effects to apply against
// public.* tables (the plugin never writes those). A throw here
Expand Down
30 changes: 28 additions & 2 deletions apps/webapp/test/directorySyncEffects.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.serve
const ENTITLED_ORG = "org_entitled";
const UNENTITLED_ORG = "org_unentitled";

function provision(organizationId: string, email = "someone@acme.com"): DirectorySyncEffect {
function provision(
organizationId: string,
email = "someone@acme.com",
roleAuthoritative = false
): DirectorySyncEffect {
return {
kind: "provision",
userId: "user_1",
Expand All @@ -40,6 +44,7 @@ function provision(organizationId: string, email = "someone@acme.com"): Director
lastName: null,
organizationId,
roleId: null,
roleAuthoritative,
};
}

Expand Down Expand Up @@ -165,7 +170,11 @@ describe("applyDirectorySyncEffects — SSO entitlement gate", () => {
devEnvironmentsQueued: false,
});

const effect = { ...provision(ENTITLED_ORG), roleId: "role_restricted" };
const effect = {
...provision(ENTITLED_ORG),
roleId: "role_restricted",
roleAuthoritative: true,
};

const { unqueuedUserIds } = await applyDirectorySyncEffects([effect]);

Expand All @@ -175,6 +184,23 @@ describe("applyDirectorySyncEffects — SSO entitlement gate", () => {
);
});

it("does not overwrite an existing role for a non-authoritative provision", async () => {
getSsoEntitlement.mockResolvedValue("entitled");

const effect = {
...provision(ENTITLED_ORG),
roleId: "role_restricted",
roleAuthoritative: false,
};

await applyDirectorySyncEffects([effect]);

expect(ensureOrgMember).toHaveBeenCalledWith(
expect.objectContaining({ roleId: "role_restricted", organizationId: ENTITLED_ORG })
);
expect(setUserRole).not.toHaveBeenCalled();
});

it("reports nothing to retry when every provision was queued", async () => {
getSsoEntitlement.mockResolvedValue("entitled");

Expand Down
22 changes: 16 additions & 6 deletions docs/limits.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ You can view your current limits, quotas, and rate limit usage in real-time by v

## Concurrency limits

| Pricing tier | Limit |
| :----------- | :------------------- |
| Free | 10 concurrent runs |
| Hobby | 25 concurrent runs |
| Pro | 100+ concurrent runs |
Concurrency is allocated **per environment**, not as a single figure for the whole organization. Each plan sets a separate default for production, staging, preview and development:

| Pricing tier | Production | Staging | Preview | Development |
| :----------- | :--------- | :------ | :------ | :---------- |
| Free | 20 | 10 | 10 | 25 |
| Hobby | 50 | 25 | 25 | 25 |
| Pro | 200+ | 100 | 100 | 25 |

These are plan defaults. The limit actually enforced for one of your environments can be higher: purchased add-on concurrency and any manual increase we have applied are both included in it. The **Concurrency** page in the dashboard (left sidebar) is authoritative for your organization — if it disagrees with the table above, the dashboard is correct.

Extra concurrency above the Pro tier limit is available via the dashboard. Click the "Concurrency" page from the left sidebar when on the Pro plan to purchase more.

Expand Down Expand Up @@ -50,7 +54,13 @@ The maximum number of runs that can be queued **per queue** (not across all queu

## Maximum run TTL

On Trigger.dev Cloud, all runs have an enforced maximum TTL of 14 days. Runs without an explicit TTL automatically receive the 14-day TTL; runs with a TTL longer than 14 days are clamped to 14 days. This prevents queued runs from accumulating indefinitely. If you self-host, you can configure a maximum TTL via the `RUN_ENGINE_DEFAULT_MAX_TTL` environment variable — see [Self-hosting environment variables](/self-hosting/env/webapp#run-engine).
On Trigger.dev Cloud, all runs have an enforced maximum TTL of 14 days. Runs without an explicit TTL automatically receive the 14-day TTL; runs with a TTL longer than 14 days are clamped to 14 days. This prevents queued runs from accumulating indefinitely.

**The TTL cap does not limit how far ahead you can schedule a run.** TTL governs how long a run may sit *queued* waiting for a concurrency slot. A run triggered with [`delay`](/triggering#delay) is not queued while it is delayed, and no TTL expiry is armed for it — the TTL clock only starts once the delay elapses and the run is enqueued. So scheduling a run a month out with `delay` works fine, and the 14 days then applies to how long it may wait for a slot after that month is up.

There is no maximum on `delay`.

If you self-host, you can configure a maximum TTL via the `RUN_ENGINE_DEFAULT_MAX_TTL` environment variable — see [Self-hosting environment variables](/self-hosting/env/webapp#run-engine).

## Schedules

Expand Down
125 changes: 124 additions & 1 deletion docs/troubleshooting-alerts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ We support receiving alerts for the following events:
- Run fails
- Deployment fails
- Deployment succeeds
- A new error group appears, regresses, or is unignored

The first three are created from the **Alerts** page. The fourth — an **Error group** alert — is created from the **Errors** page instead, but appears in the same Alerts table once created. It behaves quite differently from a run failure alert; see [Error group alerts](#error-group-alerts) below.

<Note>
If you want to be told about **every** run that fails, choose a **run fails** alert. An Error group
alert will not do this — it deliberately stays quiet once it has alerted on a given error.
</Note>

## How to setup alerts

Expand Down Expand Up @@ -36,6 +44,37 @@ Click on the triple dot menu on the right side of the table row and select "Disa
</Steps>


## Error group alerts

Error group alerts are **issue-based**, not run-based. They are created from the **Errors** page in the dashboard (the "Configure alerts…" button), not from the New alert modal on the Alerts page. Once created they show up in the Alerts table alongside your other alerts, labelled "Error group".

An error group is one distinct error — the same error from many runs is a single group, with a status of **Unresolved**, **Resolved** or **Ignored** that you set from the Errors page.

### When an error group alert fires

The alert only fires when a group's status *changes* in one of these three ways:

| Trigger | Meaning |
| :------ | :------ |
| New issue | The error has been seen for the first time. |
| Regression | The group was marked **Resolved**, and the error has occurred again since. |
| Unignored | The group was **Ignored**, and the ignore condition you set has been breached. |

### Why it goes quiet

This is the part that surprises people, so it is worth stating plainly:

**An Unresolved error group does not alert.** After an error group alert fires, the group is set to Unresolved, and it stays silent no matter how many more times that error occurs. It will only alert again once you mark it **Resolved** (and it then recurs) or **Ignored** (and the ignore condition is breached).

This is intentional — one persistently broken task should not flood your Slack channel with a message per failed run. But it means an Error group alert is not a substitute for a run failure alert. If a task has been failing in production for days and you have had no notification, check whether the only alert you have configured is an Error group alert whose group is sitting at Unresolved.

### Which alert type should I use?

- **"Tell me about every run that fails"** → a **run fails** alert, from the Alerts page. It fires for every run that fails once its retries are exhausted.
- **"Tell me when something new breaks"** → an **Error group** alert, from the Errors page.

The two are complementary, and many teams want both.

## Alert webhooks

For the alert webhooks you can use the SDK to parse them. Here is an example of how to parse the webhook payload in Remix:
Expand Down Expand Up @@ -69,6 +108,10 @@ export async function action({ request }: ActionFunctionArgs) {
console.log("[Webhook Internal Test] Deployment failed alert webhook received", { event });
break;
}
case "alert.error": {
console.log("[Webhook Internal Test] Error group alert webhook received", { event });
break;
}
default: {
console.log("[Webhook Internal Test] Unhandled webhook type", { event });
}
Expand Down Expand Up @@ -112,7 +155,7 @@ When you create a webhook alert, you'll receive different payloads depending on
</ParamField>

<ParamField path="type" type="string">
The type of alert webhook. One of: `alert.run.failed`, `alert.deployment.success`, or `alert.deployment.failed`
The type of alert webhook. One of: `alert.run.failed`, `alert.deployment.success`, `alert.deployment.failed`, or `alert.error`
</ParamField>

### Run Failed Alert
Expand Down Expand Up @@ -383,3 +426,83 @@ This webhook is sent when a deployment fails. The payload is available on the `o
Project name
</ParamField>


### Error Group Alert

This webhook is sent for an [error group alert](#error-group-alerts). The payload is available on the `object` property:

<ParamField path="object.classification" type="string">
Why the alert fired. One of: `new_issue`, `regression`, `unignored`
</ParamField>

<ParamField path="object.error.fingerprint" type="string">
Identifier for the error group
</ParamField>

<ParamField path="object.error.type" type="string">
Error type
</ParamField>

<ParamField path="object.error.message" type="string">
Error message
</ParamField>

<ParamField path="object.error.stackTrace" type="string">
Sample stack trace, if available
</ParamField>

<ParamField path="object.error.firstSeen" type="string">
When the error was first seen
</ParamField>

<ParamField path="object.error.lastSeen" type="string">
When the error was last seen
</ParamField>

<ParamField path="object.error.occurrenceCount" type="number">
Number of occurrences
</ParamField>

<ParamField path="object.error.taskIdentifier" type="string">
Task the error occurred in
</ParamField>

<ParamField path="object.environment.id" type="string">
Environment ID
</ParamField>

<ParamField path="object.environment.name" type="string">
Environment name
</ParamField>

<ParamField path="object.organization.id" type="string">
Organization ID
</ParamField>

<ParamField path="object.organization.slug" type="string">
Organization slug
</ParamField>

<ParamField path="object.organization.name" type="string">
Organization name
</ParamField>

<ParamField path="object.project.id" type="string">
Project ID
</ParamField>

<ParamField path="object.project.ref" type="string">
Project reference
</ParamField>

<ParamField path="object.project.slug" type="string">
Project slug
</ParamField>

<ParamField path="object.project.name" type="string">
Project name
</ParamField>

<ParamField path="object.dashboardUrl" type="string">
URL to view the error in the dashboard
</ParamField>
12 changes: 12 additions & 0 deletions docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ You need to be on at least these minor versions:

## Runtime issues

### Runs stuck in "Pending version"

A run sits in **Pending version** when the version deployed to that environment doesn't contain the run's task, or doesn't contain the queue it was triggered on. The run isn't lost: unless it's pinned to a specific deployment (see the last cause below), it starts automatically once a version containing both is deployed.

Usually one of:

- **The task isn't in the deployed version.** It was renamed or removed, or the deploy adding it hasn't landed yet. Check the Tasks page for that environment.
- **The queue doesn't exist in the deployed version.** Overriding `queue` at trigger time only works if a deployed task declares that queue — see [Queues](/queues).
- **You triggered into the wrong organization, project or environment.** Check the secret key you triggered with belongs to the same place you deployed to.
- **A later deploy removed or renamed the task or queue while the run was waiting.** Most likely with `delay`. Pin the run to a version to avoid this: `trigger(payload, { delay: "1h", version: "20260227.2" })`. See [atomic deployments](/deployment/atomic-deployment).
- **The run is pinned to an external deployment id that hasn't arrived.** This is [version skew protection](/deployment/version-skew-protection#waiting-and-expiry), and it behaves differently: only a deployment carrying the matching id releases the run, and it expires after 1 hour if none does.

### `Environment variable not found:`

Your code is deployed separately from the rest of your app(s) so you need to make sure that you set any environment variables you use in your tasks in the Trigger.dev dashboard. [Read the guide](/deploy-environment-variables).
Expand Down
Loading
Loading