From f8babdf9b0376c4c7c7fa235048a075862fdccfd Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 22 Sep 2026 13:57:26 +0000 Subject: [PATCH 1/7] fix(schema-to-json): support Zod 4 date schemas Fix Zod 4 date conversion in JSON Schema generation while preserving errors for other unsupported schema types. Fixes [#4939](https://github.com/triggerdotdev/trigger.dev/issues/4939). Mono-RevId: eac25ac271420ef81afc6d49bcd2df69f8116b18 --- .changeset/calm-dates-listen.md | 5 + packages/schema-to-json/src/index.ts | 53 +-------- packages/schema-to-json/src/zod4.ts | 118 ++++++++++++++++++++ packages/schema-to-json/tests/index.test.ts | 54 +++++++++ 4 files changed, 178 insertions(+), 52 deletions(-) create mode 100644 .changeset/calm-dates-listen.md create mode 100644 packages/schema-to-json/src/zod4.ts diff --git a/.changeset/calm-dates-listen.md b/.changeset/calm-dates-listen.md new file mode 100644 index 00000000000..7f5829d4834 --- /dev/null +++ b/.changeset/calm-dates-listen.md @@ -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. diff --git a/packages/schema-to-json/src/index.ts b/packages/schema-to-json/src/index.ts index 1c611388932..1afcf416b29 100644 --- a/packages/schema-to-json/src/index.ts +++ b/packages/schema-to-json/src/index.ts @@ -1,9 +1,9 @@ // Import JSONSchema from core to ensure compatibility import type { JSONSchema } from "@trigger.dev/core/v3"; import { zodToJsonSchema } from "zod-to-json-schema"; -import * as z4 from "zod/v4/core"; import { convertSchema } from "@sodaru/yup-to-json-schema"; import { JSONSchema as EffectJSONSchema } from "effect"; +import { convertZod4Schema } from "./zod4.js"; export type Schema = unknown; export type { JSONSchema }; @@ -149,57 +149,6 @@ function convertZod3Schema(schema: any, options?: ConversionOptions): JSONSchema }) as JSONSchema; } -function convertZod4Schema(schema: any, options?: ConversionOptions): JSONSchema | undefined { - const useReferences = options?.useReferences ?? false; - - return z4.toJSONSchema(schema, { - target: "draft-7", - io: "output", - reused: useReferences ? "ref" : "inline", - override: ({ zodSchema, jsonSchema }) => { - const def = zodSchema._zod.def; - if (def.type === "undefined") { - throw new Error("Undefined cannot be represented in JSON Schema"); - } - - if (def.type === "object" && jsonSchema.required) { - // Early Zod 4 permalinks do not propagate optional output through unions. - const required = jsonSchema.required.filter((key) => { - const field = def.shape[key]; - return !field || field._zod.optout === "optional" || !hasOptionalOutput(field); - }); - if (required.length > 0) { - jsonSchema.required = required; - } else { - delete jsonSchema.required; - } - } - }, - }) as JSONSchema; -} - -function hasOptionalOutput(schema: z4.$ZodType, seen = new Set()): boolean { - if (schema._zod.optout === "optional") return true; - if (seen.has(schema)) return false; - seen.add(schema); - - const def = (schema as z4.$ZodTypes)._zod.def; - switch (def.type) { - case "union": - return def.options.some((option) => hasOptionalOutput(option, seen)); - case "nullable": - case "readonly": - case "catch": - return hasOptionalOutput(def.innerType, seen); - case "lazy": - return hasOptionalOutput(def.getter(), seen); - case "pipe": - return hasOptionalOutput(def.out, seen); - default: - return false; - } -} - function isYupSchema(schema: any): boolean { return "spec" in schema && "_typeCheck" in schema; } diff --git a/packages/schema-to-json/src/zod4.ts b/packages/schema-to-json/src/zod4.ts new file mode 100644 index 00000000000..33c2b0267c8 --- /dev/null +++ b/packages/schema-to-json/src/zod4.ts @@ -0,0 +1,118 @@ +import type { JSONSchema } from "@trigger.dev/core/v3"; +import * as z4 from "zod/v4/core"; +import type { ConversionOptions } from "./index.js"; + +type Zod4Converter = { + version: { major: number; minor: number }; + toJSONSchema: (schema: any, options: any) => unknown; +}; + +export function convertZod4Schema( + schema: any, + options?: ConversionOptions, + converter: Zod4Converter = z4 +): JSONSchema | undefined { + const useReferences = options?.useReferences ?? false; + const supportsUnrepresentableHandler = + converter.version.major > 4 || (converter.version.major === 4 && converter.version.minor >= 5); + + return converter.toJSONSchema(schema, { + target: "draft-7", + io: "output", + reused: useReferences ? "ref" : "inline", + unrepresentable: supportsUnrepresentableHandler + ? ({ zodSchema }: { zodSchema: z4.$ZodTypes }) => + zodSchema._zod.def.type === "date" ? { type: "string", format: "date-time" } : "throw" + : "any", + override: ({ zodSchema, jsonSchema }: { zodSchema: z4.$ZodTypes; jsonSchema: any }) => { + const def = zodSchema._zod.def; + + if (!supportsUnrepresentableHandler) { + enforceLegacyUnrepresentableTypes(def, jsonSchema); + } + + if (def.type === "date") { + jsonSchema.type = "string"; + jsonSchema.format = "date-time"; + } + + if (def.type === "undefined") { + throw new Error("Undefined cannot be represented in JSON Schema"); + } + + if (def.type === "object" && jsonSchema.required) { + const required = jsonSchema.required.filter((key: string) => { + const field = def.shape[key]; + return !field || field._zod.optout === "optional" || !hasOptionalOutput(field); + }); + if (required.length > 0) { + jsonSchema.required = required; + } else { + delete jsonSchema.required; + } + } + }, + }) as JSONSchema; +} + +function enforceLegacyUnrepresentableTypes(def: any, jsonSchema: Record) { + switch (def.type) { + case "bigint": + throw new Error("BigInt cannot be represented in JSON Schema"); + case "symbol": + throw new Error("Symbols cannot be represented in JSON Schema"); + case "void": + throw new Error("Void cannot be represented in JSON Schema"); + case "map": + throw new Error("Map cannot be represented in JSON Schema"); + case "set": + throw new Error("Set cannot be represented in JSON Schema"); + case "transform": + throw new Error("Transforms cannot be represented in JSON Schema"); + case "nan": + throw new Error("NaN cannot be represented in JSON Schema"); + case "custom": + throw new Error("Custom types cannot be represented in JSON Schema"); + case "function": + throw new Error("Function types cannot be represented in JSON Schema"); + case "literal": { + if (def.values.some((value: unknown) => value === undefined)) { + throw new Error("Literal `undefined` cannot be represented in JSON Schema"); + } + if (def.values.some((value: unknown) => typeof value === "bigint")) { + throw new Error("BigInt literals cannot be represented in JSON Schema"); + } + return; + } + case "catch": { + if ("default" in jsonSchema) return; + try { + def.catchValue(undefined); + } catch { + throw new Error("Dynamic catch values are not supported in JSON Schema"); + } + } + } +} + +function hasOptionalOutput(schema: z4.$ZodType, seen = new Set()): boolean { + if (schema._zod.optout === "optional") return true; + if (seen.has(schema)) return false; + seen.add(schema); + + const def = (schema as z4.$ZodTypes)._zod.def; + switch (def.type) { + case "union": + return def.options.some((option) => hasOptionalOutput(option, seen)); + case "nullable": + case "readonly": + case "catch": + return hasOptionalOutput(def.innerType, seen); + case "lazy": + return hasOptionalOutput(def.getter(), seen); + case "pipe": + return hasOptionalOutput(def.out, seen); + default: + return false; + } +} diff --git a/packages/schema-to-json/tests/index.test.ts b/packages/schema-to-json/tests/index.test.ts index b54788bf1a8..dfa79bc0d41 100644 --- a/packages/schema-to-json/tests/index.test.ts +++ b/packages/schema-to-json/tests/index.test.ts @@ -1,12 +1,14 @@ import * as z3 from "zod/v3"; import * as z4 from "zod/v4"; import * as zFloor from "zod-v3-floor/v4"; +import * as zFloorCore from "zod-v3-floor/v4/core"; import * as y from "yup"; // @ts-ignore import { type } from "arktype"; import { Schema } from "effect"; import { Type } from "@sinclair/typebox"; import { schemaToJsonSchema, canConvertSchema } from "../src/index.js"; +import { convertZod4Schema } from "../src/zod4.js"; describe("schemaToJsonSchema", () => { describe("Zod schemas", () => { @@ -110,6 +112,58 @@ describe("schemaToJsonSchema", () => { "Undefined cannot be represented in JSON Schema" ); }); + + it("converts dates to date-time strings", () => { + const schema = z.object({ + createdAt: z.date(), + updatedAt: z.date().optional(), + deletedAt: z.date().nullable(), + history: z.array(z.date()), + }); + + expect(schemaToJsonSchema(schema)?.jsonSchema).toMatchObject({ + type: "object", + properties: { + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + deletedAt: { + anyOf: [{ type: "string", format: "date-time" }, { type: "null" }], + }, + history: { + type: "array", + items: { type: "string", format: "date-time" }, + }, + }, + required: ["createdAt", "deletedAt", "history"], + }); + }); + }); + + describe("legacy Zod 4 JSON Schema conversion", () => { + const convert = (schema: zFloor.ZodType) => convertZod4Schema(schema, undefined, zFloorCore); + + it("converts dates without relaxing other unrepresentable types", () => { + expect(convert(zFloor.object({ when: zFloor.date() }))).toMatchObject({ + type: "object", + properties: { + when: { type: "string", format: "date-time" }, + }, + required: ["when"], + }); + }); + + it("continues to reject unrepresentable types", () => { + expect(() => convert(zFloor.bigint())).toThrow("BigInt cannot be represented in JSON Schema"); + expect(() => convert(zFloor.symbol())).toThrow( + "Symbols cannot be represented in JSON Schema" + ); + expect(() => convert(zFloor.transform((value) => value))).toThrow( + "Transforms cannot be represented in JSON Schema" + ); + expect(() => convert(zFloor.literal(undefined))).toThrow( + "Literal `undefined` cannot be represented in JSON Schema" + ); + }); }); it("preserves explicitly required metadata on current optional schemas", () => { From 319e3b908218e475bacbc72e6befda4233e92730 Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:06:30 +0000 Subject: [PATCH 2/7] docs: correct concurrency limits, error group alerts, TTL vs delay, and pending version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four docs corrections, one per commit. **Concurrency limits** (`limits.mdx`) — the table published a single headline figure per tier, flattened from the four per-environment values a plan actually sets, and drifted from the current defaults. Now split per environment, with a note that the dashboard Concurrency page is authoritative because the enforced limit also includes purchased add-on and manual concurrency. **Error group alerts** (`troubleshooting-alerts.mdx`) — undocumented until now. They are created from the Errors page, appear in the Alerts table, and are issue-based: they fire on a new issue, a regression or an unignore, and a group that has alerted latches to Unresolved and stays quiet. People configure one expecting a notification per failed run and get silence. Documents the firing rules, the quiet steady state, and which alert type to pick for "tell me about every failed run". Also adds the `alert.error` webhook, which the reference omitted. **Maximum run TTL** (`limits.mdx`) — "all runs have an enforced maximum TTL of 14 days" reads as a cap on how far ahead you can schedule. It isn't: a delayed run arms no TTL expiry, and the TTL clock starts when the delay elapses. Also states that `delay` has no upper bound. **Pending version** (`troubleshooting.mdx`) — a new troubleshooting entry. A run parks here when the version deployed to that environment carries neither the run's task nor its queue. Lists the four causes seen in support: task missing from the deployed version, a queue no deployed task declares, triggering into the wrong org/project/environment, and a later deploy removing or renaming the task or queue while the run was waiting. Also covers runs parked by version skew protection, which wait for their specific external deployment id and expire after 1 hour. Docs only. Mono-RevId: c8a80f8c46ff160ac72fc7c1a1ef5f88a0b02f2c --- docs/limits.mdx | 22 ++++-- docs/troubleshooting-alerts.mdx | 125 +++++++++++++++++++++++++++++++- docs/troubleshooting.mdx | 12 +++ 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/docs/limits.mdx b/docs/limits.mdx index a27901a091e..c4e533f7d09 100644 --- a/docs/limits.mdx +++ b/docs/limits.mdx @@ -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. @@ -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 diff --git a/docs/troubleshooting-alerts.mdx b/docs/troubleshooting-alerts.mdx index 1d4322201b3..1f977ccf43a 100644 --- a/docs/troubleshooting-alerts.mdx +++ b/docs/troubleshooting-alerts.mdx @@ -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. + + + 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. + ## How to setup alerts @@ -36,6 +44,37 @@ Click on the triple dot menu on the right side of the table row and select "Disa +## 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: @@ -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 }); } @@ -112,7 +155,7 @@ When you create a webhook alert, you'll receive different payloads depending on - 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` ### Run Failed Alert @@ -383,3 +426,83 @@ This webhook is sent when a deployment fails. The payload is available on the `o Project name + +### Error Group Alert + +This webhook is sent for an [error group alert](#error-group-alerts). The payload is available on the `object` property: + + + Why the alert fired. One of: `new_issue`, `regression`, `unignored` + + + + Identifier for the error group + + + + Error type + + + + Error message + + + + Sample stack trace, if available + + + + When the error was first seen + + + + When the error was last seen + + + + Number of occurrences + + + + Task the error occurred in + + + + Environment ID + + + + Environment name + + + + Organization ID + + + + Organization slug + + + + Organization name + + + + Project ID + + + + Project reference + + + + Project slug + + + + Project name + + + + URL to view the error in the dashboard + diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 04ef4275e45..7770e6c2ee1 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -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). From c8d3cf1d82ae3846fadd0d8cef8f9d53f5c70d7c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 22 Sep 2026 15:11:04 +0000 Subject: [PATCH 3/7] fix(emails): clarify automatic default "node" Node 24 upgrades Mono-RevId: e6399386fe348bde57423668d5427dc7ed9ca935 --- .../emails/node-runtime-deprecation.tsx | 24 ++++++------------- internal-packages/emails/src/index.tsx | 2 +- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/internal-packages/emails/emails/node-runtime-deprecation.tsx b/internal-packages/emails/emails/node-runtime-deprecation.tsx index cf045c5116c..735220757e1 100644 --- a/internal-packages/emails/emails/node-runtime-deprecation.tsx +++ b/internal-packages/emails/emails/node-runtime-deprecation.tsx @@ -45,13 +45,13 @@ export default function Email(props: NodeRuntimeDeprecationEmailProps) { return ( - {`${project} deployed using deprecated Node.js 21`} + {`${project} deployed using deprecated Node 21`} This deployment uses deprecated Node 21 - This deployment was built using Node {runtimeVersion}. From 5 October, new deployments - on Node 21 will start to fail. + This deployment was built using Node {runtimeVersion}. From 5 October, deployments that + would otherwise use Node 21 will start using Node 24 instead. Organization: {organization} @@ -66,21 +66,11 @@ export default function Email(props: NodeRuntimeDeprecationEmailProps) { View deployment - Check all affected projects + Upgrade now - Go to your{" "} - - Projects settings - {" "} - to see which projects need updating. - - You can also use the latest version of the CLI: - npx trigger.dev@latest projects list --needs-update - - How to upgrade - - Edit trigger.config.ts in each project and set the{" "} - runtime field: + If you want to upgrade and test the change before then, edit{" "} + trigger.config.ts, set the runtime field, and deploy + again: {'runtime: "node-24"'} diff --git a/internal-packages/emails/src/index.tsx b/internal-packages/emails/src/index.tsx index 7033dbdb3a0..26bf9f894d3 100644 --- a/internal-packages/emails/src/index.tsx +++ b/internal-packages/emails/src/index.tsx @@ -176,7 +176,7 @@ export class EmailClient { } case "node-runtime-deprecation": { return { - subject: `[${data.organization}] ${data.project} deployed using deprecated Node.js 21`, + subject: `[${data.organization}] ${data.project} deployed using deprecated Node 21`, component: , }; } From 562c9433a3f70da46eb5e8b213f22f98dcc1d164 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 22 Sep 2026 16:07:13 +0000 Subject: [PATCH 4/7] feat(cli): authenticate promote with environment API keys Mono-RevId: eeea820997ad473c81c42a56fad43b2cc065624e --- .changeset/promote-environment-api-keys.md | 5 +++++ packages/cli-v3/src/cli/common.test.ts | 21 +++++++++++++++++++++ packages/cli-v3/src/cli/common.ts | 3 +-- packages/cli-v3/src/commands/login.test.ts | 15 +++++++++++++++ packages/cli-v3/src/commands/login.ts | 17 ++++++++++------- packages/cli-v3/src/commands/promote.ts | 9 ++++++--- 6 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 .changeset/promote-environment-api-keys.md create mode 100644 packages/cli-v3/src/cli/common.test.ts create mode 100644 packages/cli-v3/src/commands/login.test.ts diff --git a/.changeset/promote-environment-api-keys.md b/.changeset/promote-environment-api-keys.md new file mode 100644 index 00000000000..72c570b33a1 --- /dev/null +++ b/.changeset/promote-environment-api-keys.md @@ -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. diff --git a/packages/cli-v3/src/cli/common.test.ts b/packages/cli-v3/src/cli/common.test.ts new file mode 100644 index 00000000000..69abd365b75 --- /dev/null +++ b/packages/cli-v3/src/cli/common.test.ts @@ -0,0 +1,21 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { commonOptions } from "./common.js"; + +describe("commonOptions", () => { + it("leaves the API URL unset when it is not explicitly provided", () => { + const command = commonOptions(new Command()); + + command.parse(["node", "trigger"]); + + expect(command.opts().apiUrl).toBeUndefined(); + }); + + it("preserves an explicitly provided API URL", () => { + const command = commonOptions(new Command()); + + command.parse(["node", "trigger", "--api-url", "https://trigger.example.com"]); + + expect(command.opts().apiUrl).toBe("https://trigger.example.com"); + }); +}); diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index 2a4f9f83cde..5b0f09de5a3 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -3,7 +3,6 @@ import type { Command } from "commander"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import { BundleError } from "../build/bundle.js"; -import { CLOUD_API_URL } from "../consts.js"; import { chalkError } from "../utilities/cliOutput.js"; import { formatCommandError } from "../utilities/commandError.js"; import { readAuthConfigCurrentProfileName } from "../utilities/configFiles.js"; @@ -22,7 +21,7 @@ export type CommonCommandOptions = z.infer; export function commonOptions(command: Command) { return command .option("--profile ", "The login profile to use", readAuthConfigCurrentProfileName()) - .option("-a, --api-url ", "Override the API URL", CLOUD_API_URL) + .option("-a, --api-url ", "Override the API URL") .option( "-l, --log-level ", "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", diff --git a/packages/cli-v3/src/commands/login.test.ts b/packages/cli-v3/src/commands/login.test.ts new file mode 100644 index 00000000000..23afd1ec13d --- /dev/null +++ b/packages/cli-v3/src/commands/login.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { CLOUD_API_URL } from "../consts.js"; +import { resolveLoginOptions } from "./login.js"; + +describe("resolveLoginOptions", () => { + it("uses the cloud API when the caller passes an undefined override", () => { + expect(resolveLoginOptions({ defaultApiUrl: undefined }).defaultApiUrl).toBe(CLOUD_API_URL); + }); + + it("preserves an explicit API URL", () => { + expect( + resolveLoginOptions({ defaultApiUrl: "https://trigger.example.com" }).defaultApiUrl + ).toBe("https://trigger.example.com"); + }); +}); diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index 1f19655a77d..8fb1a8ae4bf 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -40,7 +40,6 @@ import { import { links } from "@trigger.dev/core/v3"; const LoginCommandOptions = CommonCommandOptions.extend({ - apiUrl: z.string(), browser: z.boolean().default(true), }); @@ -98,15 +97,19 @@ export type LoginOptions = { browser?: boolean; }; +export function resolveLoginOptions(options?: LoginOptions) { + return { + embedded: false, + silent: false, + ...options, + defaultApiUrl: options?.defaultApiUrl ?? CLOUD_API_URL, + }; +} + export async function login(options?: LoginOptions): Promise { return await tracer.startActiveSpan("login", async (span) => { try { - const opts = { - defaultApiUrl: CLOUD_API_URL, - embedded: false, - silent: false, - ...options, - }; + const opts = resolveLoginOptions(options); span.setAttributes({ "cli.config.apiUrl": opts.defaultApiUrl, diff --git a/packages/cli-v3/src/commands/promote.ts b/packages/cli-v3/src/commands/promote.ts index 2360648b99e..bb22c4cb800 100644 --- a/packages/cli-v3/src/commands/promote.ts +++ b/packages/cli-v3/src/commands/promote.ts @@ -14,6 +14,7 @@ import { getProjectClient } from "../utilities/session.js"; import { login } from "./login.js"; import { createGitMeta } from "../utilities/gitMeta.js"; import { getBranch } from "@trigger.dev/core/v3"; +import { authenticateForDeploy } from "../deploy/auth.js"; const PromoteCommandOptions = CommonCommandOptions.extend({ projectRef: z.string().optional(), @@ -70,10 +71,12 @@ async function _promoteCommand(version: string, options: PromoteCommandOptions) intro(`Promoting version ${version}`); - const authorization = await login({ - embedded: true, - defaultApiUrl: options.apiUrl, + const authorization = await authenticateForDeploy({ + accessToken: process.env.TRIGGER_ACCESS_TOKEN, + apiUrl: process.env.TRIGGER_API_URL ?? options.apiUrl, profile: options.profile, + silent: false, + login, }); if (!authorization.ok) { From 21146b18b274a70487213031ff8f972c4db5e2b3 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:04:47 +0000 Subject: [PATCH 5/7] fix(core): use zod/v4 for the snapshot route schema so warm starts work on zod 3 projects (#4972) ## Summary Deployments built with 4.6.0 to 4.6.3 in projects where `zod` resolves to a 3.x release could not warm start. Every run handed to a warm runner made the runner exit before it started the attempt; the run then waited until the platform's heartbeat redrive requeued it and it started cold, a few minutes late. Cold starts were unaffected, which is why this surfaced as delayed starts rather than errors. ## Root cause The zod v4 migration ([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039)) moved core's schemas to `zod/v4`. `snapshotRoute.ts` landed shortly after, still importing the Zod 3 API from `"zod"`, and `SnapshotRouteWire` is composed into `DequeuedMessage` and the worker attempt request bodies. Zod 4 rejects a Zod 3 schema inside a Zod 4 object at parse time, regardless of the input: ``` Invalid element at key "snapshotRoute": expected a Zod schema ``` Inside the monorepo the root `zod` resolves to 4.x, so nothing failed here. In a user's image with zod 3.x installed, the warm-start client's `DequeuedMessage.parse()` threw on every run and the controller exited. ## Fix One import: `snapshotRoute.ts` now uses `zod/v4`. The existing Zod 3 root compatibility test gains a case that bundles `DequeuedMessage` and `WorkerApiRunAttemptStartRequestBody` against a Zod 3 root and parses them; it fails on `main` and passes here. Also verified by packing the built package and parsing a `DequeuedMessage` with `zod@3.25.76` installed. --- .changeset/snapshot-route-zod-v4.md | 5 ++ .../schemaCompositionCompatibility.test.ts | 79 +++++++++++++++++++ packages/core/src/v3/schemas/snapshotRoute.ts | 2 +- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/snapshot-route-zod-v4.md diff --git a/.changeset/snapshot-route-zod-v4.md b/.changeset/snapshot-route-zod-v4.md new file mode 100644 index 00000000000..6ee27fa05e0 --- /dev/null +++ b/.changeset/snapshot-route-zod-v4.md @@ -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. diff --git a/packages/core/src/v3/schemas/schemaCompositionCompatibility.test.ts b/packages/core/src/v3/schemas/schemaCompositionCompatibility.test.ts index 41888a8a6e4..513dd8bf4ed 100644 --- a/packages/core/src/v3/schemas/schemaCompositionCompatibility.test.ts +++ b/packages/core/src/v3/schemas/schemaCompositionCompatibility.test.ts @@ -91,4 +91,83 @@ describe("schema composition compatibility", () => { resource: { id: "my-webhook" }, }); }); + + it.each([ + { name: "zod", v3: require.resolve("zod/v3"), v4: require.resolve("zod/v4") }, + { + name: "zod-v3-floor", + v3: require.resolve("zod-v3-floor/v3"), + v4: require.resolve("zod-v3-floor/v4"), + }, + ])("parses run engine worker schemas with $name and a Zod 3 root", async ({ v3, v4 }) => { + // The warm-start client parses DequeuedMessage inside the deployed runner, where the root + // "zod" import resolves to whatever the user's project installed. A leaf schema built on the + // Zod 3 API and composed into a Zod 4 object throws on every parse, not just on bad input. + const result = await build({ + stdin: { + contents: ` + import { DequeuedMessage } from "./src/v3/schemas/runEngine.ts"; + import { WorkerApiRunAttemptStartRequestBody } from "./src/v3/runEngineWorker/supervisor/schemas.ts"; + + const snapshotRoute = { version: 1, residency: "postgres", organizationId: "org_1" }; + + export const parsed = { + dequeued: DequeuedMessage.parse({ + version: "1", + snapshotRoute, + dequeuedAt: "2026-01-01T00:00:00.000Z", + snapshot: { + id: "snapshot_1", + friendlyId: "snapshot_1", + executionStatus: "PENDING_EXECUTING", + description: "Run was dequeued for execution", + createdAt: "2026-01-01T00:00:00.000Z", + }, + completedWaitpoints: [], + backgroundWorker: { id: "worker_1", friendlyId: "worker_1", version: "20260101.1" }, + deployment: { id: "deployment_1", friendlyId: "deployment_1" }, + run: { + id: "run_1", + friendlyId: "run_1", + isTest: false, + machine: { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0 }, + attemptNumber: 1, + masterQueue: "main", + traceContext: {}, + }, + environment: { id: "env_1", type: "PRODUCTION" }, + organization: { id: "org_1" }, + project: { id: "proj_1" }, + }), + attemptStart: WorkerApiRunAttemptStartRequestBody.parse({ isWarmStart: true, snapshotRoute }), + }; + `, + loader: "ts", + resolveDir: packageRoot, + }, + bundle: true, + format: "esm", + platform: "node", + target: "node20", + write: false, + plugins: [ + { + name: "resolve-root-zod-to-v3", + setup(build) { + build.onResolve({ filter: /^zod$/ }, () => ({ path: v3 })); + build.onResolve({ filter: /^zod\/v4$/ }, () => ({ path: v4 })); + }, + }, + ], + }); + + const bundledModule = await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0]!.text).toString("base64")}` + ); + + expect(bundledModule.parsed).toMatchObject({ + dequeued: { run: { id: "run_1" }, snapshotRoute: { residency: "postgres" } }, + attemptStart: { isWarmStart: true, snapshotRoute: { residency: "postgres" } }, + }); + }); }); diff --git a/packages/core/src/v3/schemas/snapshotRoute.ts b/packages/core/src/v3/schemas/snapshotRoute.ts index a592c458983..2dead573fc5 100644 --- a/packages/core/src/v3/schemas/snapshotRoute.ts +++ b/packages/core/src/v3/schemas/snapshotRoute.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from "zod/v4"; /** * A run's versioned storage route (control metadata, never a TRES column), stamped on the queue message From f3aeba9da6cb620d8b23c6445fd34566ebf2e570 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 22 Sep 2026 17:32:27 +0000 Subject: [PATCH 6/7] fix(core): require explicit Zod version imports Mono-RevId: 6d4cc748ca1f7190f58e2983529b65d7326568af --- .oxlintrc.json | 9 ++++++- oxlint-plugins/zod-imports.mjs | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 oxlint-plugins/zod-imports.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json index 7d863dcbe73..09d56823c89 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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/**", @@ -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": { diff --git a/oxlint-plugins/zod-imports.mjs b/oxlint-plugins/zod-imports.mjs new file mode 100644 index 00000000000..d324ce8f791 --- /dev/null +++ b/oxlint-plugins/zod-imports.mjs @@ -0,0 +1,43 @@ +/** @type {import("eslint").Rule.RuleModule} */ +const requireVersionedImport = { + meta: { + type: "problem", + docs: { + description: + "Require an explicit Zod version import in packages that support multiple Zod major versions.", + }, + fixable: "code", + messages: { + unversioned: + 'Import Zod 4 from "zod/v4" rather than "zod". The package supports Zod 3 and 4, so the bare import can resolve to the wrong major version.', + }, + schema: [], + }, + create(context) { + function checkSource(node) { + if (node.source?.value !== "zod") return; + + context.report({ + node: node.source, + messageId: "unversioned", + fix: (fixer) => fixer.replaceText(node.source, '"zod/v4"'), + }); + } + + return { + ImportDeclaration: checkSource, + ExportAllDeclaration: checkSource, + ExportNamedDeclaration: checkSource, + }; + }, +}; + +/** @type {import("eslint").ESLint.Plugin} */ +const plugin = { + meta: { name: "trigger-zod" }, + rules: { + "require-versioned-import": requireVersionedImport, + }, +}; + +export default plugin; From 9d7a60bc144d3ac03820bfe6b01cd4ba37349db1 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 22 Sep 2026 18:06:44 +0000 Subject: [PATCH 7/7] fix(webapp,plugins): additive Directory Sync roles + retryable account webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory Sync is now additive: connecting a directory no longer resets or removes the roles of members you added yourself. A member's role changes only when they belong to a group you've explicitly mapped to a role — new groups start unmapped ("Inherit") and change nothing until you map them. Owner can now be assigned from the Directory Sync and SSO role menus. Out-of-order webhooks from your identity provider are retried instead of dropped, so directory setup is more reliable. Mono-RevId: cd148230d7708141c30a13a4aae0e5ba2a4bfa5e --- .../route.tsx | 14 +++++---- .../services/directorySyncEffects.server.ts | 4 +-- .../app/v3/accountsWebhookWorker.server.ts | 6 +++- .../test/directorySyncEffects.server.test.ts | 30 +++++++++++++++++-- packages/plugins/src/sso.ts | 17 ++--------- 5 files changed, 44 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e3500d5398e..f3dfb89488a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -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, @@ -810,7 +808,7 @@ function ActiveConnectionState({ /> value={draftJitRoleId} @@ -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" @@ -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") } > diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts index 9ede3897a05..44be1d926f2 100644 --- a/apps/webapp/app/services/directorySyncEffects.server.ts +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -88,9 +88,7 @@ async function applyEffect(effect: DirectorySyncEffect): Promise 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, diff --git a/apps/webapp/app/v3/accountsWebhookWorker.server.ts b/apps/webapp/app/v3/accountsWebhookWorker.server.ts index fb8fb88b5ac..9ee4712c4bf 100644 --- a/apps/webapp/app/v3/accountsWebhookWorker.server.ts +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -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 diff --git a/apps/webapp/test/directorySyncEffects.server.test.ts b/apps/webapp/test/directorySyncEffects.server.test.ts index 0d884c06355..737e7a35657 100644 --- a/apps/webapp/test/directorySyncEffects.server.test.ts +++ b/apps/webapp/test/directorySyncEffects.server.test.ts @@ -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", @@ -40,6 +44,7 @@ function provision(organizationId: string, email = "someone@acme.com"): Director lastName: null, organizationId, roleId: null, + roleAuthoritative, }; } @@ -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]); @@ -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"); diff --git a/packages/plugins/src/sso.ts b/packages/plugins/src/sso.ts index 8d53ae101da..9b4fc85a161 100644 --- a/packages/plugins/src/sso.ts +++ b/packages/plugins/src/sso.ts @@ -99,17 +99,6 @@ export type DirectorySyncStatus = { groups: ReadonlyArray; }; -// A host-actionable membership mutation derived from a directory-sync event. -// The plugin owns all `enterprise.*` writes; these effects describe the -// `public.*` (User / OrgMember / role / token) writes the host must perform — -// the plugin never touches those tables. The host worker applies them -// idempotently. -// -// - `provision`: ensure the User exists (create when `userId === null`), -// ensure the OrgMember exists, and set its role. -// - `deprovision`: remove the membership (guarded against last-Owner), force -// logout, and revoke tokens per host policy. -// - `set_role`: overwrite the member's role (directory-authoritative). export type DirectorySyncEffect = | { kind: "provision"; @@ -119,6 +108,7 @@ export type DirectorySyncEffect = lastName: string | null; organizationId: string; roleId: string | null; + roleAuthoritative: boolean; } | { kind: "deprovision"; userId: string; organizationId: string } | { kind: "set_role"; userId: string; organizationId: string; roleId: string }; @@ -147,10 +137,7 @@ export type SsoPortalError = "idp_org_unavailable" | "internal"; // session is NOT an error: it's a successful result of `{ valid: false }`. export type SsoValidateError = "internal"; -// Inbound webhook handling. `invalid_signature` → reject (4xx, no retry); -// `feature_disabled` → no plugin installed (host returns 404); `internal` -// → transient, the host returns 5xx so the provider retries. -export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal"; +export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal" | "not_ready"; // A verified, JSON-serializable inbound event. Vendor-neutral envelope — // `event` is the provider's event-type string, `data` its opaque payload.