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/.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/.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/.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/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/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). 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: , }; } 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; 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) { 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 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. 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", () => {