Compare commits

...

332 Commits

Author SHA1 Message Date
Mihai Popescu e0998e6049 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-02-08 21:57:34 +02:00
nicktrn e536d35b17 fix(ci): fix docker image publishing and worker builds (#3013)
## Summary
- **Fix Docker publish automation**: The `v.docker.*` tags pushed by the
release workflow using `GITHUB_TOKEN` don't trigger the publish workflow
(GitHub Actions limitation to prevent infinite loops). Added a
`workflow_call` to `publish.yml` directly from the release job so Docker
images are built automatically after npm publish. Tags are still pushed
for reference.
- **Fix worker Containerfiles**: The coordinator, docker-provider, and
kubernetes-provider builds have been failing since the superjson
vendoring change in `@trigger.dev/core` (#2949). The Containerfiles now
run `bundle-vendor` before `build:bundle` to generate the vendor files
that esbuild needs.

### Context
- Docker images on GHCR have been stuck at v4.3.0 — v4.3.1, v4.3.2,
v4.3.3 tags existed on GitHub but never triggered publish runs
- The worker builds (publish-worker) have been failing on every push to
main since Jan 30

## Test plan
- [x] Verified kubernetes-provider Containerfile builds locally with the
fix
- [x] Manually dispatched publish workflow for v4.3.1 — all jobs
succeeded
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3013"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-06 13:56:39 +00:00
DKP b96a0b70d4 Docs: Clarify AI tool compatibility and expand context snippet (#3000)
This pull request overhauls the "Building with AI" documentation
section. It includes a comprehensive restructuring of the main
building-with-ai page with new setup guides and troubleshooting
sections, reorganizes the navigation hierarchy to elevate
mcp-agent-rules as a top-level page, and updates multiple documentation
pages to clarify the relationships between three AI tools: Skills, Agent
Rules, and MCP Server. Changes also include formatting improvements,
such as replacing italicized text with inline code formatting, and
consistent additions of explanatory Note blocks and CardGroup components
across related pages.
2026-02-05 15:22:44 -08:00
James Ritchie 3bb9aac014 Fix(webapp): Prevent big numbers on Queue page from jumping around when animating (#3007) 2026-02-05 07:45:40 -08:00
Saadi Myftija 283f88b203 feat(webapp): add triggered via field to deployment details page (#2850)
Display the deployment trigger source (CLI, CI/CD, Dashboard, GitHub
Integration) with appropriate icons on the deployment details page. The
triggeredVia field was already in the database but not displayed.

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-05 15:24:23 +01:00
James Ritchie c55af7bead fix(webapp): ask ai button missing tooltip (#2964)
Fixes
- the tooltip not displaying on the AskAI button in the side menu
- incorrect AskAI button heights
- Small UI tweaks
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2964">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Mihai Popescu <mihai.popescu.dev@gmail.com>
2026-02-05 03:17:18 +02:00
Iss db4fb9eeef docs: Add Hookdeck example (#3005)
Add documentation for integrating Hookdeck with Trigger.dev to receive
webhooks and forward them to Trigger.dev tasks.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3005">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 16:50:59 -08:00
mpcgird f99c780c28 fixed on leave edge case 2026-02-05 00:34:49 +02:00
mpcgird 171c50f72a Added event for recalculating vertical line hover effect on timeline span 2026-02-04 23:19:31 +02:00
mpcgird dcd73f129e Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-02-04 18:45:09 +02:00
Iss c0595700f8 docs: clarify .env.local loading and idempotency key reset scope (#2996)
Document that .env.local is automatically loaded during dev, and clarify
that backend-triggered idempotency keys should be reset with global
scope
2026-02-04 09:37:35 -05:00
Iss 6a45f5623b docs: multi-tenant applications and concurrency limits (#2961)
Adds an example of multi-tenant applications as alternative to
project/limit increase, and more info about queue times and concurrency
2026-02-04 09:33:05 -05:00
Iss 104f720f6f docs(troubleshooting): add COULD_NOT_FIND_EXECUTOR error and IPv4 support (#2950)
Document COULD_NOT_FIND_EXECUTOR error with dynamic imports and IPv4
database connection limitation in troubleshooting guide.
2026-02-04 09:30:51 -05:00
Iss e017913021 docs(self-hosting): added graphile worker troubleshooting to docs (#2883)
Add troubleshooting documentation for graphile worker schema migration
failures and PostgreSQL SSL certificate issues that prevent worker
initialization.
2026-02-04 09:29:01 -05:00
Matt Aitken 7781e2aad1 docs: usage function examples were missing the imports (#2830)
Closes #2828
2026-02-04 14:05:59 +00:00
Saadi Myftija 8e0034484c feat(supervisor): project-based scheduling affinity for image cache locality (#2995)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
Adds optional pod affinity so pods from the same project prefer
scheduling on the same node. This can help improve image cache hit
rates; subsequent pods benefit from already-pulled image layers,
reducing startup time.

Complements the built-in ImageLocality scheduler plugin by helping
during burst scheduling scenarios. Pod affinity sees scheduled pods
immediately, while ImageLocality only sees images after they're fully
pulled.

Configuration:
- `KUBERNETES_PROJECT_AFFINITY_ENABLED` - Enable/disable (default:
false)
- `KUBERNETES_PROJECT_AFFINITY_WEIGHT` - Scheduler weight 1-100
(default: 50)
- `KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY` - Topology key (default:
kubernetes.io/hostname)

Uses soft (preferred) affinity so pods always schedule even if preferred
node is full.

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2995">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 14:39:47 +01:00
Eric Allam b72cacc671 feat(debounce): add maxDelay option to limit total debounce time (#2984) 2026-02-02 20:15:06 +00:00
Mihai Popescu 1ccb8c186f changed schema id (#2983)
Fixed duplicate schema id for clickhouse
2026-02-02 02:43:39 +02:00
nicktrn 279102c17c fix(cli): reject execute() immediately when child process is dead (#2978)
## Summary
- When a child process crashes and a retry (`RETRY_IMMEDIATELY`) is
attempted on the same `TaskRunProcess`, `execute()` hangs forever
because the IPC send is silently skipped and the attempt promise can
never resolve
- This caused runner pods to stay up indefinitely with no heartbeats or
polls
- Fix: reject the attempt promise immediately when the child is not
connected, so the controller can proceed to warm start or exit

## Test plan
- [x] Added `taskRunProcess.test.ts` — verifies `execute()` rejects
promptly instead of hanging when the child process is dead
- [x] Deploy and verify no more stuck runner pods accumulate over time
2026-01-30 16:44:46 +00:00
Eric Allam b221719c09 chore(repo): fixed missing dependency in pnpm lockfile (#2976)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2976">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 10:02:30 +00:00
Matt Aitken e6861f4fe4 Fix: run page logs keep refreshing when a run finishes (#2971)
Closes #2798

When a run finished the logs UI could get stuck and so be pending and
never update again. If you did a hard reload it would be correct.

This happened because when we insert a log/span we ping Redis which
causes a reload of the UI. However there was a race condition – the
insert into ClickHouse can take a while so we were refreshing the UI too
early. Then never refreshing it again.
  
Changes
- Send refresh pings every 5s to keep run page logs live
- Throttle updates so the run UI is never updated more than once per
second
- Stop auto-reloading when a run has been completed for >= 30s
- Add type inference improvements for the throttle function
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2971">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 09:47:56 +00:00
Matt Aitken bc7ce78103 fix(sdk): export AnyOnStartAttemptHookFunction type (#2966)
Export AnyOnStartAttemptHookFunction type to allow defining
onStartAttempt hooks for individual tasks.

https://claude.ai/code/session_018jgSVcFtKVyv65ktGNQFFq
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2966">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-30 09:17:37 +00:00
Eric Allam 9937823a7f Standardize @types/node to version 20.14.14 across monorepo (#2970)
##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Description

This PR standardizes the `@types/node` dependency across the entire
monorepo to version `20.14.14`. Previously, different packages were
using different versions (ranging from 12.20.55 to 22.13.9), which could
cause type conflicts and inconsistencies.

### Changes Made

1. **tsconfig.json** - Added `"node"` to the `types` array in
`apps/webapp/tsconfig.json` to ensure Node.js types are properly
recognized
2. **package.json overrides** - Added `@types/node` version override to
`20.14.14` in the root `package.json`
3. **pnpm-lock.yaml** - Updated lock file to reflect the standardized
version across all packages and their dependencies
4. **Fixture package.json** - Updated
`packages/cli-v3/e2e/fixtures/emit-decorator-metadata/package.json` to
use the standardized version

This ensures consistent type definitions across the monorepo and
prevents version mismatches that could lead to type errors or unexpected
behavior.

---

## Testing

- Verified that all package references to `@types/node` now point to
version `20.14.14`
- Confirmed that the lock file properly reflects the override across all
transitive dependencies
- Ensured TypeScript configuration includes Node.js types for proper
type checking

---

## Changelog

- Standardized `@types/node` to version `20.14.14` across all packages
in the monorepo
- Added `"node"` to TypeScript compiler types in webapp configuration
- Updated all package dependencies to use the consistent version through
pnpm overrides

💯

https://claude.ai/code/session_018eqp2LvvErkFSN9oK5xBh1
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2970">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:35 +00:00
Eric Allam 3925f8cc49 fix(core): vendor superjson to fix ESM/CJS compatibility (#2949)
Bundle superjson and its dependency (copy-anything) during build to
avoid
ERR_REQUIRE_ESM errors on Node.js versions that don't support
require(ESM)
by default (< 22.12.0) and AWS Lambda which intentionally disables it.

- Add scripts/bundle-superjson.mjs to bundle superjson with esbuild
- Update build script to bundle vendor files before tshy compilation
- Move superjson from dependencies to devDependencies
- Update imports to use vendored bundles

Fixes #2937
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2949">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:02 +00:00
Iss 01208fde27 chore(docs): Add playwright workaround (#2973)
Adds a workaround to playwright to browser download failures based on
this GH issue: https://github.com/triggerdotdev/trigger.dev/issues/2440
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2973">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 08:43:23 +00:00
Eric Allam 5e049cde3a fix(run-engine): avoid NAPI string overflow in getExecutionSnapshotsSince by only fetching waitpoints for latest snapshot (#2972) 2026-01-29 19:03:54 +00:00
Matt Aitken 72c357125b Query enabled via feature flag (#2968)
- Renamed FeatureFlag functions to be singular where it makes sense.
- Added function to handle multiple feature flags
- canAccessQuery now checks the global feature flag and environment
variable as well
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2968">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 15:30:56 +00:00
DKP 0674d74bbb Added Trigger.dark theme to the docs (#2967)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2967">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:59:27 +00:00
DKP 9e08712749 Add building with ai/skills pages and updated intro (#2962)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2962">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:55:43 +00:00
Matt Aitken f53db6fd16 Query: time limits, performance improvements, styling (#2953)
Summary
- Query: add time limits, performance improvements, and styling updates

Changes
- Add ClickHouse output_text and error_text columns with indexes
- Automatically use _text columns for JSON based on query pattern;
support JSON column data prefixes
- Add idempotency key and scope columns
- Add enforcedWhereClause for tenant and time restrictions, instead of
the old tenant stuff.
- Implement basic time filter limiting and set default time period based
on plan; show message when results are clipped
- UX: resizable code area (including vertical splits), collapsible
sidebar, fix table/chart vertical sizing, max height for chart legend in
fullscreen
- Styling and UI tweaks: improved chart legend styling, more chart
colours, thinner line chart stroke, pricing callout color, improved
layout for callouts
- Features: generate and save AI titles
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2953">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:02:47 +00:00
Oskar Otwinowski c0b86efbd3 feat(webapp): Add MiddleTruncate component for long task names (#2946)
Closes #

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

Tested the MiddleTruncate component in the TasksDropdown by:
1. Verifying that long task names (e.g.,
"namespace:category:subcategory:task-name") are truncated in the middle
2. Confirming the full text appears in a tooltip on hover
3. Testing responsive behavior - truncation adjusts when the container
is resized
4. Verifying that short task names that fit within the container are
displayed in full without truncation

---

## Changelog

Added a new `MiddleTruncate` primitive component that intelligently
truncates text in the middle while preserving the beginning and end
portions. This is particularly useful for long hierarchical identifiers
like task slugs.

**Key features:**
- Truncates text in the middle with an ellipsis (…) when it exceeds
available width
- Shows full text in a tooltip on hover when truncated
- Responsive - recalculates truncation on container resize using
ResizeObserver
- Maintains minimum character visibility (4 chars minimum on each side
for readability)
- Integrated into TasksDropdown to handle long task names

**Changes:**
- Created new `MiddleTruncate.tsx` component with binary search
algorithm for optimal character distribution
- Updated TasksDropdown to use MiddleTruncate for task slug display
- Increased TasksDropdown popover width from 240px to 360px to provide
better space for truncated text

---

## Screenshots
💯


https://github.com/user-attachments/assets/a7a2191a-2e36-437e-ab3f-517fe7620b93


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2946">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 12:51:58 +01:00
Mihai Popescu e29e1c86d9 Fix/tri 7032 logs page feedback (#2947)
##  Changes
### UI & UX
- Normalized log level display across table and detail view
- Fixed table header scroll behavior and sidebar positioning
- Improved loading state with taller segment and disabled resizing
- Added "no more logs" message with count
- Enhanced keyboard shortcuts
### Filtering & Search
- Streamlined filters: RunId and Task only (removed run filters)
- Side panel closes when filters change
- Fixed logs from previous search remaining in table
- Fixed table scroll position when changing filters
### Backend
- Added performance indexes on message and attributes
(`014_add_task_runs_v2_search_indexes.sql`)
- Added DEBUG level logging by default
- Removed internal logs from display
- Fixed ServiceValidationError forwarding to frontend
  - Removed v1 logs API support
2026-01-29 12:47:59 +02:00
mpcgird 2066843998 Merge remote-tracking branch 'origin/main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-01-28 21:08:37 +02:00
James Ritchie 34203d6a6f Fix(webapp) prevent incidents url being called every frame (#2956) 2026-01-27 18:33:36 +00:00
James Ritchie d4e4fbd7fc fix(webapp): Truncate long branch names to prevent breaking the onboarding layout (#2954)
### Before
<img width="1736" height="964" alt="CleanShot 2026-01-21 at 20 11 08@2x"
src="https://github.com/user-attachments/assets/76c6ed42-d2a0-4212-aaad-6fb848025c85"
/>

### After
<img width="1776" height="1032" alt="CleanShot 2026-01-27 at 17 32
47@2x"
src="https://github.com/user-attachments/assets/030ba26e-6aad-4232-a9ff-f2fde9931f8c"
/>

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2954">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 18:26:43 +00:00
James Ritchie 49de105862 Feat(webapp): collapsible side menu (#2939)
- Better separation between main feature pages and manage pages
- Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button
- Collapsed state persisted to database (survives page refresh)
- All menu items show tooltips when collapsed
- Smooth animations using Framer Motion and CSS transitions
- Impersonation-safe (preferences not modified when impersonating)


https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 17:13:21 +00:00
DKP 5fb9cc36bc fix(security): upgrade CLI deps and add overrides (#2952)
- Upgrade @modelcontextprotocol/sdk 1.24.0 → 1.25.2 (CVE-2026-0621
ReDoS)
- Upgrade tar 7.4.3 → 7.5.4+ (CVE-2026-23950 race condition)
- Add pnpm overrides for transitive deps:
  - qs <6.14.0 → 6.14.0 (CVE-2025-15284 DoS)
  - systeminformation <5.27.14 → 5.27.14 (CVE-2025-68154 cmd injection)
  - lodash <4.17.23 → 4.17.23 (CVE-2025-13465 prototype pollution)

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-01-27 16:33:04 +00:00
nicktrn 70c8d6d14b fix(react-hooks): prevent onComplete from firing prematurely when stream disconnects (#2929)
## Summary

Fixes #2856 - The `onComplete` callback in `useRealtimeRun` was firing prematurely

## Root Cause

The callback was triggered when the long-poll stream ended, regardless
of whether the run had actually completed. Reverse proxies often close
idle connections, causing the stream to end prematurely. In this case it
was caused by fetch abort due to React strict mode.

## Fix

Changed the condition from checking if `run` exists to checking if
`run?.finishedAt` exists, ensuring `onComplete` only fires when the run
has reached a terminal state.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <nicktrn@users.noreply.github.com>
2026-01-27 16:21:56 +00:00
Eric Allam eeab6bdeac fix(run-engine): fix queue cache memory leak and replace MemoryStore with LRU cache (#2945)
- Fix memory leak in RunAttemptSystem queue cache - was keying by runId
instead of queue identifier
- Replace `@unkey/cache` MemoryStore with new LRUMemoryStore for O(1)
operations and better memory bounds

## Problem

### Cache Key Bug
The queue cache in `#resolveTaskRunExecutionQueue` was keyed by `runId`,
creating one cache entry per run instead of per queue. With 1-2 hour
TTLs and 5000 entry soft cap, these accumulated causing memory growth.

### MemoryStore Performance
The `@unkey/cache` MemoryStore uses O(n) synchronous iteration for
eviction, blocking the event loop at high throughput.

## Solution

### Cache Key Fix
Changed cache key from `params.runId` to queue identifier:

```typescript
const cacheKey = params.lockedQueueId ?? `${params.runtimeEnvironmentId}:${params.queueName}`;
```

LRU Cache

Created LRUMemoryStore adapter using lru-cache package:
- O(1) get/set/delete operations
- Strict memory bounds (hard max vs soft cap)
- No event loop blocking

Test Results:

| Metric | Before Fix | After Fix |
|---|---|---|
| Queue cache entries (per 1000 runs) | ~1000 | 1 |
| Old space growth | 32.27 MB | 5.45 MB |
| Heap growth | 7.21 MB (3.9%) | 4.81 MB (2.6%) |


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2945">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-26 22:23:23 +00:00
Matt Aitken 825219a2f4 Fix: requeue a run if the DB is unavailable during dequeuing (#2938)
In the DequeueSystem if the database is unavailable we were dequeuing
from Redis and then failing to requeue in the error catcher – this was
because the requeuing required DB access.

Now if in the `catch` we encounter a DB error we requeue directly using
Redis, putting it back in the queue.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2938">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-25 19:23:04 +00:00
bharath kumar b143027d95 Fix/sdk stream root fallback (#2874) 2026-01-25 06:53:26 +00:00
Eric Allam 409388365e fix(fair-queue): ensure concurrency is released when a message reaches visibility timeout to prevent concurrency leaks (#2907)
## Summary

Fixes a concurrency leak in the batch queue where visibility timeout
reclaims do not release concurrency slots.

**The bug:** When a message visibility timeout expires (60s),
`reclaimTimedOut` puts the message back in the queue but does NOT
release the concurrency slot. The messageId stays in the concurrency set
(`engine:batch:concurrency:tenant:{envId}`), counting against the tenant
limit even though the message is no longer in-flight.

This causes:
1. Tenant appears at capacity when checking `SCARD >= limit`
2. New messages get released back to queue instead of being processed
3. Messages stuck in infinite loop, master queue grows indefinitely

**The fix:**
- Modified `reclaimTimedOut` to capture message data (including
tenantId) BEFORE releasing from in-flight
- Returns `ReclaimedMessageInfo[]` with messageId, queueId, tenantId,
and metadata
- `#reclaimTimedOutMessages` now iterates over reclaimed messages and
calls `concurrencyManager.release()` for each

## Test plan

- [x] Added test: `should return reclaimed message info with tenantId
for concurrency release`
- [x] Added test: `should return empty array when no messages have timed
out`
- [x] Added test: `should reclaim multiple timed-out messages and return
all their info`
- [x] Updated `raceConditions.test.ts` for new return type
- [x] All tests passing
- [ ] Monitor production after deploy for concurrency leak recurrence

refs TRI-7049
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2907">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 13:06:35 +00:00
Gautam Singh fe5178f3e8 feat(webapp): add priority to task test and replay options (#2936)
Closes #2934

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

1. make sure there are no worker connected or the queue is paused.
2. run hello world tasks and assign priority
3. notice the priority value in task context.
4. try running a replay for task with priority
5. notice the context priority
---

## Changelog

_[Short description of what has changed]_
Added option to set priority on task test and replay dialog
---

## Screenshots

_[Screenshots]_
<img width="953" height="804" alt="image"
src="https://github.com/user-attachments/assets/d452b872-6c05-4a9e-a1e3-f6a283bb363d"
/>
<img width="958" height="886" alt="image"
src="https://github.com/user-attachments/assets/8f9f3096-da99-4d81-a9c5-2f3004b89ceb"
/>
<img width="1022" height="736" alt="image"
src="https://github.com/user-attachments/assets/233e01ac-a42b-4b4f-ac3e-e9b18638b971"
/>


💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2936">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 09:10:14 +00:00
Iss a3f1eb2361 chore(docs): Admin requirements for creating worker groups (#2925)
Adds documentation for creating additional worker groups via the admin
API endpoint, including how to make users admin (new vs existing users),
and clarifies that ADMIN_EMAILS only applies on signup.
2026-01-24 09:00:50 +00:00
Iss ab4b50b95a chore(docs): Add clarifications for delay semantics and payload handling (#2928)
Adds documentation notes clarifying that:
- delayed runs execute on the current deployment version
- guidance for Date objects in payloads
- static IP availability
- version locking behavior for delayed runs
2026-01-24 08:59:55 +00:00
Eric Allam 1859fd0283 chore(docs): Improvements to the idempotency key docs for the user expored itempotency key changes (#2910) 2026-01-23 18:07:12 +00:00
github-actions[bot] 700fe91bec chore: release v4.3.3 (#2893)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/build@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

## trigger.dev@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`
    -   `@trigger.dev/build@4.3.3`
    -   `@trigger.dev/schema-to-json@4.3.3`

## @trigger.dev/core@4.3.3

### Patch Changes

- Add support for AI SDK v6 (Vercel AI SDK)
([#2919](https://github.com/triggerdotdev/trigger.dev/pull/2919))

    -   Updated peer dependency to allow `ai@^6.0.0` alongside v4 and v5
- Updated internal code to handle async validation from AI SDK v6's
Schema type

- Expose user-provided idempotency key and scope in task context.
`ctx.run.idempotencyKey` now returns the original key passed to
`idempotencyKeys.create()` instead of the hash, and
`ctx.run.idempotencyKeyScope` shows the scope ("run", "attempt", or
"global").
([#2903](https://github.com/triggerdotdev/trigger.dev/pull/2903))

- Fix batch trigger failing with "ReadableStream is locked" error when
network failures occur mid-stream. Added safe stream cancellation that
gracefully handles locked streams during retry attempts.
([#2917](https://github.com/triggerdotdev/trigger.dev/pull/2917))

- Add a maxDepth to flatten/unflattenAttributes to prevent possible
issues ([#2890](https://github.com/triggerdotdev/trigger.dev/pull/2890))

## @trigger.dev/python@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.3.3`
    -   `@trigger.dev/core@4.3.3`
    -   `@trigger.dev/build@4.3.3`

## @trigger.dev/react-hooks@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

## @trigger.dev/redis-worker@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

## @trigger.dev/rsc@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

## @trigger.dev/schema-to-json@4.3.3

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

## @trigger.dev/sdk@4.3.3

### Patch Changes

- Add support for AI SDK v6 (Vercel AI SDK)
([#2919](https://github.com/triggerdotdev/trigger.dev/pull/2919))

    -   Updated peer dependency to allow `ai@^6.0.0` alongside v4 and v5
- Updated internal code to handle async validation from AI SDK v6's
Schema type

- Expose user-provided idempotency key and scope in task context.
`ctx.run.idempotencyKey` now returns the original key passed to
`idempotencyKeys.create()` instead of the hash, and
`ctx.run.idempotencyKeyScope` shows the scope ("run", "attempt", or
"global").
([#2903](https://github.com/triggerdotdev/trigger.dev/pull/2903))

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.3`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-23 17:36:11 +00:00
Matt Aitken 6055c7d050 Recover runs that failed to dequeue (#2931)
There’s an edge case that means runs can end up in the
currentConcurrency set when they’re not in the correct state for
execution. This means they will be permanently stuck in queued.

Given an environmentId this will fix those runs.

This is a temporary fix while we permanently fix the issue.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-23 16:25:58 +00:00
Matt Aitken cf1c311dea Use the replica for API auth queries (#2932)
When we auth API keys we get the environment, project and org. This is a
very hot path so even though these queries are fast they contribute a
significant percentage of total load.

This moves them to use the read replica instead.
2026-01-23 16:23:51 +00:00
Eric Allam fb94e1741f fix(dashboard): display correct batch rate limit current token value (#2927) 2026-01-23 16:13:29 +00:00
Eric Allam 022f69c8c8 fix(v3): redesign of batch completion to prevent heavy row-level contention on the BatchTaskRun (#2930) 2026-01-23 15:52:52 +00:00
Eric Allam d893b26ed2 fix(engine): store costInCents and usageDurationMs on the TaskRun table via existing run engine updates (#2926)
Moving usage updates into the run engine to prevent inefficient &
additional incremental updates to the TaskRun table. Read/Modify/Write
pattern is safe inside of the run engine because of the run lock. We can
also now cap the usageDurationMs value from overflowing and causing an
error.

## Why?

This is preventing at least one update per TaskRun and instead updating
these values piggybacking on other updates.

## Aurora PostgreSQL Reader Consistency Notes

### TL;DR
Aurora readers share the same storage as the writer, but maintain
separate in-memory page caches. This means:
- **Storage is always consistent** - writes are synchronously committed
to shared storage
- **Page cache can lag** - typically <100ms, but can cause stale reads
if data is cached

### How It Works
1. Writer commits to shared storage (synchronous 4/6 quorum)
2. Writer sends cache invalidation messages to readers (asynchronous)
3. If reader has data in cache → returns cached (potentially stale)
value
4. If reader has cache miss → fetches from shared storage (always
current)

### Monitoring
```sql
SELECT server_id,
       CASE WHEN session_id = 'MASTER_SESSION_ID' THEN 'Writer' ELSE 'Reader' END AS role,
       replica_lag_in_msec
FROM aurora_replica_status();
```
2026-01-23 12:34:04 +00:00
Eric Allam 6f26acb581 fix(run-engine): use reader for pending version queries (#2924)
Move expensive findMany queries for PENDING_VERSION and
WAITING_FOR_DEPLOY
runs to read replicas to avoid blocking migrations on the primary
database.

Changes:
- Add readOnlyPrisma to SystemResources type
- Pass readOnlyPrisma to systems in RunEngine constructor  
- Update pendingVersionSystem to use readOnlyPrisma for findMany
- Update executeTasksWaitingForDeploy to use _replica for findMany
2026-01-21 15:37:21 +00:00
Eric Allam bd449f75dc fix(migrations): Add IF NOT EXISTS to 20260116154810_add_idempotency_key_options_to_task_run (#2923)
## Summary
- Adds `IF NOT EXISTS` to the migration that adds
`idempotencyKeyOptions` column to prevent errors if the column already
exists

## Migration Checksum Fix

If you've already applied the previous version of this migration, you'll
need to update the checksum in your `_prisma_migrations` table to match
the new migration file.

**Previous checksum:**
`f8876e274e3f7735312275eb24a9c4b40f512ac12a286b2de3add47f66df5b27`
**New checksum:**
`0620a914ddbaf01279576274432e51c41f41502cd4c8de38621625380750e397`

### Fix instructions

Run this SQL command against your database:

```sql
UPDATE "_prisma_migrations"
SET checksum = '0620a914ddbaf01279576274432e51c41f41502cd4c8de38621625380750e397'
WHERE migration_name = '20260116154810_add_idempotency_key_options_to_task_run';
```

This updates the stored checksum to match the modified migration file,
allowing future migrations to proceed without checksum mismatch errors.

## Test plan
- [x] Verified migration applies cleanly on fresh database
- [ ] Verified checksum update works on database with previous migration
applied

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-01-21 14:43:37 +00:00
Saadi Myftija cd2f536620 feat(docker): enable skipping db migrations on container startup (#2922)
Adds support for skipping Postgres migrations on container startup via
the new `SKIP_POSTGRES_MIGRATIONS` environment variable.

Set `SKIP_POSTGRES_MIGRATIONS=1` to skip migrations, matching the
existing behavior of `SKIP_CLICKHOUSE_MIGRATIONS`.
2026-01-21 13:34:00 +00:00
Matt Aitken 3056a51b82 Query improvements (#2905)
What changed
- Upgraded recharts to 2.15.2
- Added multiple chart types and components: big number, line, stacked,
bar (including zoomable & reference line), big dataset bar, and usage
graph
- Implemented custom legend with animated values, tooltip showing x-axis
data, and hover/highlight behaviors for stacks and legend
- Added loading, no-data, and invalid chart states plus loading spinners
and improved loading animations/layout
- Storybook integration: initial charts setup, separate chart files,
alphabetized menu, chart state toggles, and story updates
- Interaction & UX improvements: zooming (drag/select), crosshair
pointer, show/select dates while zooming, prevent text selection on
drag, hide mouse wheel zoom, capped legend items, axis/legend styling
tweaks, better spacing, and min-height for charts
- Data & state handling: moved date data to route for unified zooming,
moved chartState to main Chart component, moved hard-coded/mock data out
of components, and set chart data when zooming to start/end dates
- Performance & animation: turned off/reduced chart animations, sped up
animated numbers, removed hover transitions for bars
- New UI primitives and layout: Card component, small card updates, SVG
icons, improved segmented control and popover variants, table
improvements (resizable columns, filtering, sorting, scrolling fixes)
- Various fixes and polish: tooltip style fixes, legend value updates,
hover/leave state resets, bar width fixes for small datasets,
type/import fixes, and numerous small style/typo tweaks

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-01-21 13:07:07 +00:00
Eric Allam 23ec5ff8fa feat(sdk): add support for AI SDK v6 (#2919)
## Summary
- Add support for Vercel AI SDK v6 as a peer dependency
- Update internal code to handle async validation from AI SDK v6's
Schema type

Closes #2918

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-20 22:31:41 +00:00
Eric Allam 31e4753122 fix(sdk): handle locked ReadableStream when retrying batch trigger (#2917)
When fetch crashes mid-stream during batch item upload (e.g., connection
reset, timeout), the request stream may remain locked by fetch's
internal reader. Attempting to cancel a locked stream throws 'Invalid
state: ReadableStream is locked', causing the batch operation to fail.

Added safeStreamCancel() helper that gracefully handles locked streams
by catching and ignoring the locked error. The stream will be cleaned up
by garbage collection when fetch eventually releases the reader.

Fixes customer issue where batchTrigger failed with ReadableStream
locked error during network instability.
2026-01-20 15:55:23 +00:00
Eric Allam 8bc6b99285 fix(batch-queue): allow batch queue consumers to run independently from the run engine worker (#2916)
new environment variable `BATCH_QUEUE_WORKER_ENABLED` now can be used
independently from `RUN_ENGINE_WORKER_ENABLED`
2026-01-20 15:26:55 +00:00
Eric Allam 87167524cc fix(api): prevent null idempotency keys in responses, only undefined accepted (#2912) 2026-01-20 14:06:37 +00:00
Eric Allam 36168b3eb6 feat(sdk): expose user-provided idempotency key and scope in task context (#2903)
## Summary
- Store the original user-provided idempotency key and scope alongside
the hash
- Expose `ctx.run.idempotencyKey` as the user-provided key (not the
hash)
- Add `ctx.run.idempotencyKeyScope` to show the scope ("run", "attempt",
or "global")

<img width="539" height="450" alt="CleanShot 2026-01-19 at 11 40 46"
src="https://github.com/user-attachments/assets/b6f42991-697e-4314-a164-aef77b8fd25c"
/>

  ## Problem
Idempotency keys were hashed (SHA-256) before storage, making debugging
difficult since users couldn't see the value they originally set or
search for runs by idempotency key.

  ## Solution
Attach metadata to the `String` object returned by
`idempotencyKeys.create()` using a Symbol, extract it in the SDK before
the API call, and store it in the database alongside the hash.

  ```typescript
const key = await idempotencyKeys.create("my-key", { scope: "global" });
  await childTask.triggerAndWait(payload, { idempotencyKey: key });

  // In child task:
  ctx.run.idempotencyKey      // "my-key" (previously showed the hash)
  ctx.run.idempotencyKeyScope // "global"
```

  Test plan

  - Trigger task with idempotencyKeys.create() using different scopes (run, attempt, global)
  - Verify ctx.run.idempotencyKey returns user-provided key
  - Verify ctx.run.idempotencyKeyScope returns correct scope
  - Verify PostgreSQL stores idempotencyKeyOptions JSON
  - Verify ClickHouse receives idempotency_key_user and idempotency_key_scope via replication

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-01-20 11:23:07 +00:00
mintlify[bot] c859be9c53 Document new Limits page in dashboard (#2908)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
Added documentation for the new Limits page feature that allows users to
view their current limits, quotas, and rate limit usage in real-time
from the dashboard. The page displays rate limit token availability,
quota usage, and plan features for organizations.

## Files changed
- `docs/limits.mdx` - Added introductory paragraph about the new Limits
page in the dashboard

Generated from [feat(webapp): New limits
page](https://github.com/triggerdotdev/trigger.dev/pull/2885) @samejr

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: James Ritchie <james@trigger.dev>
2026-01-19 11:30:02 +00:00
Saadi Myftija b7f7d88623 feat(supervisor): add per-machine-preset resource request ratios (#2906)
Adds support to configure CPU/memory request ratios per machine preset.
Falls back to the global request ratio configs if no specific override
is specified.

Runs across different machine presets have different usage patters, so
this enables use to manage the available capacity better.
2026-01-19 12:26:05 +01:00
Eric Allam 72594a46ee fix(dashboard): properly cleanup trace pubsub redis clients to redis/memory/elu leaks in the dashboard (#2901) 2026-01-16 15:20:30 +00:00
Matt Aitken 5504e7f8cc Fix schedule limit counting (#2899)
Fix schedule counting
- Inactive TaskSchedule not counted
- Inactive environments (archived preview branches) not counted

Clarified per-project limits descriptions.
2026-01-16 13:56:49 +00:00
James Ritchie 7a7c4b1a82 feat(webapp): New limits page (#2885)
<img width="1381" height="1362" alt="CleanShot 2026-01-14 at 13 41 02"
src="https://github.com/user-attachments/assets/0537dccf-60c7-4ab7-a0e4-3164eac1e97d"
/>

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-01-15 18:03:06 +00:00
Dan 733894bb4f Impersonation log (#2896)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-01-15 16:54:45 +00:00
Matt Aitken b696bbb1df Add TaskScheduleInstance projectId (#2897)
Summary
- Add nullable projectId field to TaskScheduleInstance.
- Create an index for TaskScheduleInstance.environmentId (added only if
it doesn’t exist, handled concurrently).
- Ensure TaskScheduleInstance.projectId is set everywhere in the
codebase.

Backfilling projectIds, once this is live

```sql
UPDATE "TaskScheduleInstance" tsi
SET "projectId" = ts."projectId"
FROM "TaskSchedule" ts
WHERE tsi."taskScheduleId" = ts."id";
```
2026-01-15 15:06:52 +00:00
Eric Allam aa69b9027d fix(repo): undo node.js supervisor upgrades and use the multiplatform node.js digest in Dockerfile (#2895) 2026-01-15 11:49:37 +00:00
Mihai Popescu 0b0df071bf logs-page-fixes (#2889)
* Removed EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT
* Added hasLogsPageAccess featureFlag for logs page
* Replaced attributes with attributes_text for logs to reduce memory
usage and improve query performance
* Added support for event_v1 for logs, now depending on the settings the
logs are fetched either from `task_events_v1` or `task_events_v2`
* Show an error in the interface in cast the repository store is
`postgres`
2026-01-15 11:07:01 +00:00
Eric Allam 936bddf198 fix: upgrade Node.js to 20.20.0 to address async_hooks DoS vulnerability (#2890)
## Summary

- Upgrades Node.js from 20.19.0 to 20.20.0 (and 22.12.0 to 22.22.0 for
supervisor) to address the async_hooks stack overflow DoS vulnerability
- Adds `maxDepth` parameter (default 128) to `flattenAttributes` and
`unflattenAttributes` to prevent stack overflow on maliciously deep
nested structures

## Details

The vulnerability (patched in Node.js 20.20.0, 22.22.0, 24.13.0, 25.3.0)
causes unrecoverable crashes (exit code 7) when stack overflow occurs
during async_hooks callbacks. Since the webapp uses `AsyncLocalStorage`,
it was theoretically vulnerable.

### Changes

**Node.js version updates:**
- `docker/Dockerfile`: 20.11.1 → 20.20.0
- `apps/supervisor/Containerfile`: 22-alpine → 22.22.0-alpine
- `.nvmrc`: 20.19.0 → 20.20.0
- `apps/supervisor/.nvmrc`: 22.12.0 → 22.22.0
- `references/prisma-7/.nvmrc`: 20.19.0 → 20.20.0
- All GitHub workflows: 20.19.0 → 20.20.0

**Defense in depth:**
- Added `maxDepth` parameter to `flattenAttributes()` and
`unflattenAttributes()` in `packages/core` to prevent stack overflow on
deeply nested user input

## Test plan

- [x] All existing `flattenAttributes` tests pass (50 tests)
- [x] New tests for depth limiting added
- [x] Verify Docker builds work with new base images
2026-01-15 10:47:44 +00:00
Eric Allam b1e21cf03b feat(api): add admin endpoint for updating org feature flags (#2891) 2026-01-15 10:41:34 +00:00
Eric Allam c3f2d07708 chore(repo): remove claude code review (keep claude bot) (#2887) 2026-01-15 09:32:30 +00:00
SHIVA REDDY VANJA 4dc504d9d0 docs: fix waitpoint token completion request body field (#2888)
Fixed documentation examples to use correct 'data' field instead of
'output' for the waitpoint token completion endpoint.

The API schema expects 'data' in the request body, but all code examples
(curl, Python, Ruby, Go) incorrectly showed 'output', causing waitpoints
to complete with empty/undefined output when users followed the docs.

Fixes #2872

Closes #<issue>

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_
This is simple doc fix
---

## Changelog

_[Short description of what has changed]_
Documentation example for the specific api route had a wrong field,
fixed that to have correct field

---

## Screenshots

_[Screenshots]_
<img width="780" height="252" alt="Screenshot 2026-01-14 at 10 49 50 PM"
src="https://github.com/user-attachments/assets/50a03f2c-edfb-4bd4-bb72-a0fd79e77216"
/>
The above image shows the correct request format, but docs previously
had incorrect payload.
<img width="723" height="307" alt="Screenshot 2026-01-14 at 11 24 01 PM"
src="https://github.com/user-attachments/assets/b9096225-3f7c-4511-b8c2-e8144c896900"
/>
This is the exact wrong field in docs, that was fixed
https://trigger.dev/docs/wait-for-token#from-another-language


💯

Co-authored-by: appdevelopers9a <appdeveloper@s9alabs.com>
2026-01-14 19:33:39 +00:00
Eric Allam a5339a1bac fix(dashboard): remove span ID to original span ID cache and use a different approach to link to the cached runs (#2886) 2026-01-14 14:36:29 +00:00
Matt Aitken 5b07bd11a0 chore: FeatureFlag add createdAt and updatedAt (#2880)
It’s useful to know when they were modified for debugging and auditing.

For existing rows createdAt and updatedAt are set to now() during the
migration, to avoid a nullable column.
2026-01-14 14:28:06 +00:00
nicktrn 260fb7cd70 fix(helm): support secrets.existingSecret for core secrets (#2860)
When users set `secrets.enabled=false` to use an external secret via
`secrets.existingSecret`, the environment variables `SESSION_SECRET`,
`MAGIC_LINK_SECRET`, `ENCRYPTION_KEY`, and `MANAGED_WORKER_SECRET` were
not being populated from the secret.

Fixes #2859

Also adds automatic helm prereleases for PRs

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <nicktrn@users.noreply.github.com>
2026-01-14 10:44:08 +00:00
Copilot 9934627c0b Revert "Set EVENT_REPOSITORY_DEFAULT_STORE default to clickhouse_v2" (#2881)
Reverts commit 495a2531f0.

Restores `EVENT_REPOSITORY_DEFAULT_STORE` default from `"clickhouse_v2"`
back to `"postgres"`.

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Single-line env schema default change; no runtime testing required.

---

## Changelog

- Reverted `EVENT_REPOSITORY_DEFAULT_STORE` default value from
`"clickhouse_v2"` to `"postgres"` in `apps/webapp/app/env.server.ts`

---

## Screenshots

N/A - configuration change only

💯

<!-- START COPILOT CODING AGENT SUFFIX -->



<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> Can you revert the latest commit
495a2531f0 and create a PR for the revert


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/triggerdotdev/trigger.dev/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>
2026-01-14 10:38:08 +00:00
Matt Aitken 495a2531f0 Set EVENT_REPOSITORY_DEFAULT_STORE default to clickhouse_v2 (#2879)
Change EVENT_REPOSITORY_DEFAULT_STORE default from "postgres" to
"clickhouse_v2" so new deployments favor the ClickHouse v2 event store
by default. This updates runtime behavior to use the newer store
implementation unless explicitly overridden.
2026-01-13 18:06:00 +00:00
Matt Aitken 1bca378000 Query fixes (#2876)
Don’t allow aliased columns to be queried – it was actually safe but
confusing. We call `created_at` -> `triggered_at` but we still allowed
created_at which was confusing.

Now we have nice errors if you try select columns that aren’t
selectable.

Also removed a ClickHouse setting `allow_experimental_object_type` which
worked fine locally but stopped all queries working on ClickHouse Cloud
🤦‍♂️
2026-01-13 17:43:25 +00:00
nicktrn b042b0b2f9 fix(webapp): cancel button in API key regeneration modal (#2878)
The Cancel button was missing an onClick handler to close the modal
dialog. This caused confusing behavior where clicking Cancel would not
dismiss the dialog. Also added type="button" to prevent form submission
since the button is inside a form.

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-13 18:26:13 +01:00
Dan 3be55db8f8 Add smart spreadsheet docs (#2877) 2026-01-13 17:21:00 +00:00
Eric Allam bb253400a2 perf(runs-replication): Improve the CPU efficiency and throughput of the runs replication to clickhouse (#2866)
## Summary

Optimizes the runs replication service for better CPU efficiency and
throughput when inserting task runs into ClickHouse.

### Key Changes

- **Switch to compact array format** - Uses
`JSONCompactEachRowWithNames` instead of `JSONEachRow` for ClickHouse
inserts, reducing JSON serialization overhead
- **Type-safe tuple arrays** - Introduces `TaskRunInsertArray` and
`PayloadInsertArray` tuple types with compile-time column order
validation
- **Pre-sorted batch inserts** - Sorts inserts by primary key before
flushing for better ClickHouse insert performance
- **Programmatic index generation** - `TASK_RUN_INDEX` and
`PAYLOAD_INDEX` are generated from column arrays to prevent manual
synchronization errors

### Files Changed

- `runsReplicationService.server.ts` - Core optimization to use compact
array inserts
- `@internal/clickhouse` - Added `insertCompactRaw` method and tuple
types
- `taskRuns.ts` - Column definitions, index constants, and insert
functions

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 12:18:30 +00:00
Mihai Popescu c8686b5f1c feat: tri-6738 Create aggregated logs page (#2862)
Closes #<issue>

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing
- Verified log detail view displays correctly with message, metadata,
and attributes
- Tested search highlighting functionality in log messages (escapes
special regex characters)
- Confirmed tabs (Details/Run) switch properly with keyboard shortcuts
(d/r)
  - Verified run information loads via async fetcher in Run tab
  - Tested close button and Escape key for dismissing the panel
- Verified log details display correct information: level badges, kind
badges, timestamps, trace IDs, span IDs
  - Confirmed links to parent spans and run pages work correctly
- Tested with various log levels (ERROR, WARN, INFO, DEBUG, TRACE) and
kinds (SPAN, SPAN_EVENT, LOG_*)
- Verified admin-only fields display correctly when user has admin
access
- Tested data loading states and error states (log not found, run not
found)


---

## Changelog

Created new Logs page. 
The information shown is gathered from the spans from each run.
The feature supports all run filters with two new filters for level and
logs text search.


---

## Screenshots

<img width="2059" height="1196" alt="Logs page preview"
src="https://github.com/user-attachments/assets/70b667b4-98cc-4728-855a-2766dd5c1aa5"
/>

💯

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-01-13 11:21:13 +00:00
Iss dfb46d8847 chore(docs): add WASM external configuration documentation (#2871)
Adds a WASM block to the External section documenting that WASM packages
must be added to the `external` array in `trigger.config.ts`.
2026-01-13 11:12:54 +00:00
Matt Aitken 9942518e49 TRQL/Query improvements (#2870)
Summary
- Improve query experience and safety across ClickHouse and TSQL.

Changes
- Display JSON columns when in non-pretty mode (no longer show [Object
Object]).
- Sanitize ClickHouse errors originating from TSQL.
- Remove tenant details from errors.
- Add AI-assisted error-fixing for queries.
- Improve code quality and readability.
- Provide autocomplete support for enum values.
- Enforce limits on ClickHouse queries (10s query limit).
- Add org-level and global concurrency limits.
- Warn and train AI to avoid SELECT *; when used, only return core
columns and show info.
- If AI suggests no time range, default to past 7 days.
- Format the default query for readability.
- Add an admin-only EXPLAIN button.
- Prevent impersonation queries from being saved to history.
2026-01-13 11:12:15 +00:00
Saadi Myftija a3c387697e feat(supervisor): add node affinity rules for large machine worker pool scheduling (#2869)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
**Background**
Runs with `large-1x` or `large-2x` machine presets are disproportionally
affected by scheduling delays during peak times. This is in part caused
by the fact that the worker pool is shared for all runs, meaning large
runs compete with smaller runs for available capacity. Because large
runs require significantly more CPU and memory, they are harder for the
scheduler to bin-pack onto existing nodes, often requiring a node with a
significant amount of free resources or waiting for a new node to spin
up entirely. This effect is amplified during peak times when nodes are
already densely packed with smaller workloads, leaving insufficient
contiguous resources for large runs. Also, large runs make up a small
percentage of the total runs.

**Changes**

This PR adds Kubernetes node affinity settings to separate large and
standard machine workloads across node pools.

- Controlled via `KUBERNETES_LARGE_MACHINE_POOL_LABEL` env var (disabled
when not set)
- Large machine presets (large-*) get a soft preference to schedule on
the large pool, with fallback to standard nodes
- Non-large machines are excluded from the large pool via required
anti-affinity
- This ensures the large machine pool is reserved for large workloads
while allowing large workloads to spill over to standard nodes if needed
2026-01-13 10:54:47 +01:00
James Ritchie 7a9490893c fix(webapp): Fix for missing table rows divider lines in safari (#2873) 2026-01-12 21:14:11 +00:00
James Ritchie 768206c91b feat(dashboard): Upgrade to the dateTime filter UI (#2864)
UI/UX improvement to the date/time picker:

- You can now choose a custom duration
- Adds a new DateTimePicker.tsx component, using a new shadcn
Calendar.tsx component
- Clear UI separation between the 2 actions, choosing a duration or
choosing date range
- Adds new quick select options for picking a date range quickly


https://github.com/user-attachments/assets/6b59b49d-2a56-4354-ad72-d8426437e56e

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-12 13:01:17 +00:00
mpcgird 12f508a949 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-01-12 14:35:32 +02:00
Eric Allam 2c30c2defb chore(claude): add CLAUDE.md and claude skill for writing trigger.dev tasks + add 4.3.0 rule set (#2867)
- Add CLAUDE.md providing Claude Code guidance and documenting the
Claude Code skill
- Add trigger-dev-tasks skill to assist writing Trigger.dev tasks
- Add SDK rules version 4.3.0 including batch trigger v2 and debouncing
features
2026-01-12 11:01:06 +00:00
Matt Aitken 839d5e8eba Allow query access when impersonating (#2863)
Co-authored-by: Mihai Popescu <mihai.popescu.dev@gmail.com>
2026-01-09 14:57:09 +00:00
Eric Allam 42dfb87ebb chore(ci): pre-pull test container images to prevent dockerhub rate limiting when running tests (#2861) 2026-01-09 12:15:53 +00:00
Matt Aitken 49df40cb11 TRQL and the Query page (#2843)
TRQL (pronounced Treacle like the delicious British dark sweet syrup) is
the TRiggerQueryLanguage. It allows users to safely write queries on
their data. The queries are safely turned into ClickHouse queries which
are tenant-safe and not SQL injectable.


https://github.com/user-attachments/assets/bbfca473-b3fc-4150-8fe6-79e8840a2d29

This started out as a translation of HogQL by PostHog from Python to
TypeScript.

Features
- Tenant safe queries.
- Many underlying ClickHouse features including functions and
aggregations.
- Virtual columns, which are exposed to users as real columns but are
actually expressions.
- Transformations of data types and where clauses.
- Simple JSON path querying.
- Limits on execution time.
- Reporting of query statistics.

## Query page

There’s a new Query page (currently behind a feature flag) where you can
write TRQL queries and execute them against your environment, project or
organization.

Features
- Executing TRQL queries
- Syntax highlighting and errors
- Autocomplete
- AI generation/editing of queries
- Help and examples
- Table with auto-inferred data types from the table schema
- Table cell renderers for our special types like Run ids, environments,
machines, tasks, queues, etc.
- Copy/export as CSV/JSON
- Line and bar graphs with grouping and stacking
- History of queries
2026-01-09 11:39:36 +00:00
Eric Allam cf0aa9b3ca core(claude): correctly setup repo and deps for claude code agent (#2858) 2026-01-09 10:27:23 +00:00
Eric Allam a8024afd0a chore(claude): add some allowed tools to the claude github workflow agent (#2857) 2026-01-09 10:17:29 +00:00
Eric Allam 57ba2528b2 feat(runs): use metrics instead of spans in the Runs Replication service (#2851) 2026-01-08 15:56:44 +00:00
Eric Allam 7d29f5aac9 fix(webapp): use useFetcher for env var edits to preserve scroll/toggle state (#2847)
Replaces `redirectDocument` with `useFetcher` for editing environment
variables. This allows background form submission without full page
reload, which preserves:

- Scroll position in the env vars list
- "Reveal values" toggle state
- Search filter state

Fixes #2845

Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-08 12:51:07 +00:00
Eric Allam 36b0762100 feat(metrics): add observable gauge for batch queue worker length (#2848) 2026-01-08 12:50:46 +00:00
Eric Allam f5b2ccb494 chore(docs): add docs for handling batch trigger errors (#2838) 2026-01-08 11:33:43 +00:00
github-actions[bot] f73138fe6c chore: release v4.3.2 (#2811)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/build@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## trigger.dev@4.3.2

### Patch Changes

- fix(cli): update command should preserve existing package.json order
([#2810](https://github.com/triggerdotdev/trigger.dev/pull/2810))
-   Updated dependencies:
    -   `@trigger.dev/build@4.3.2`
    -   `@trigger.dev/core@4.3.2`
    -   `@trigger.dev/schema-to-json@4.3.2`

## @trigger.dev/python@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.3.2`
    -   `@trigger.dev/build@4.3.2`
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/react-hooks@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/redis-worker@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/rsc@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/schema-to-json@4.3.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/sdk@4.3.2

### Patch Changes

- Improve batch trigger error messages, especially when rate limited
([#2837](https://github.com/triggerdotdev/trigger.dev/pull/2837))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.2`

## @trigger.dev/core@4.3.2

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-08 11:26:59 +00:00
Eric Allam 7c2e78c9de fix(batch): more high cardinality metric attribute fixes (#2846) 2026-01-08 10:07:49 +00:00
mintlify[bot] 6dfbe1d762 Document spike alerts feature (#2844)
Updated the billing alerts documentation to reflect the new spike alerts
feature added in PR #2829. The documentation now explains both standard
alerts (75%, 90%, 100%, 200%, 500%) and spike alerts (10x, 20x, 50x,
100x) that help catch runaway usage from bugs or errors.

## Files changed
- `docs/how-to-reduce-your-spend.mdx` - Added section explaining the two
types of billing alerts

Generated from [Chore(webapp): Adds additional billing
alerts](https://github.com/triggerdotdev/trigger.dev/pull/2829) @samejr

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-01-07 16:40:04 +00:00
James Ritchie 3f19b98f9f Fix(webapp): Dynamically load spline on 404 on page (#2834)
Remove the spline dependency and load it dynamically on the 404 page.

Uses a web component with a workaround for React.
2026-01-07 15:50:49 +00:00
Eric Allam 09460ab37d feat: add event loop block notifications and env flag (#2842)
Add colored console warnings when the event loop is blocked and wire
a feature flag to enable/disable notifications- Introduce
notifyEventLoopBlocked() in eventLoopMonitor.server.ts to
  log a colored warning with blocked and async type.
- Call notifyEventLoopBlocked() when an event-loop stall is detected.
- Add EVENT_LOOP_MONITOR_NOTIFY_ENABLED to env schema with a default of
  "0" so notifications are off by default.
- Will notify when over the `EVENT_LOOP_MONITOR_THRESHOLD_MS` env var

This makes it easier to spot long event-loop stalls during development
or when notifications are explicitly enabled.

<img width="840" height="132" alt="CleanShot 2026-01-07 at 15 03 24@2x"
src="https://github.com/user-attachments/assets/be20fa6a-be2b-46a1-aa89-d0913ed8b5b3"
/>
2026-01-07 15:04:50 +00:00
Eric Allam 062766e974 fix(batch): optimize processing batch trigger v2 (#2841)
This PR fixes some issues with the new BatchQueue by implementing the
full two-phase dequeue process in the FairQueue, and moving the
responsibility of consuming the worker queue to the BatchQueue and
independently enabling it via the `BATCH_QUEUE_WORKER_QUEUE_ENABLED` env
var. We've also introduced the `BATCH_QUEUE_SHARD_COUNT` env var to
control the count of master queue shards in the FairQueue. We can also
control how many queues are considered in each iteration of the master
queue consumer via the `BATCH_QUEUE_MASTER_QUEUE_LIMIT` env var.

This PR will also now skip trying to dequeue from tenants that are at
concurrency capacity, which should lead to fewer issues with low
concurrency tenants blocking higher concurrency tenants from processing.
2026-01-07 15:00:09 +00:00
James Ritchie c68eb2d951 fix(webapp): concurrency limits modal cancels and resets limit on enter (#2804)
The Override concurrency limit modal has 2 type="submit" buttons. The
first one in the DOM was firing when the "enter" key is hit which
canceled and reset the limit instead which is a bad UX.

### The fix
This fix adds a hidden button above in the DOM order which mirrors the
Update Override button. Having a double submit button is rare in our
modals so feels safe to add this to the specific modal that needs it.

### Alternative solution
Switching the order of the buttons in the main FormButton component,
then using `flex-row-reverse` to flip them back in CSS works, but it
reverses the tab order. Adding a `tabIndex` to fix that issue didn't
seem to work reliably.
2026-01-07 14:14:23 +00:00
James Ritchie 583d299891 chore(webapp): Concurrency page UI improvements (#2825)
### UI Improvements to the Concurrency page: 

- Truncates long branch names and includes a tooltip
- The Tables have a new variant if you don't want the rows to highlight
on hover
- Small fix to pluralize some words in the purchase modal
- Fix to prevent tooltip buttons being `type=submit`
- Updates the /limits docs page to include purchasing more concurrency
- Adds a clear banner when you have a positive balance of unallocated
concurrency



https://github.com/user-attachments/assets/54d927c3-84e3-4d55-8f42-726098f4daf0
2026-01-07 14:13:36 +00:00
James Ritchie 17338050b1 Chore(webapp): Adds additional billing alerts (#2829)
- Adds 4 additional alert thresholds to ensure customers are emailed if
they have runaway usage.
- Separated these into a new section called "Spike alerts" with a
tooltip so it's clear what they are.
- Tooltip message is: "Catch runaway usage from bugs or errors. We
recommend keeping these enabled as a safety net."
- A billing service PR now returns all orgs to populate the email list,
rather than oldest 5.
- Adds `defaultChecked` logic to honour existing orgs who have
configured alerts in the DB. New orgs get all alerts checked on by
default.

<img width="1316" height="1560" alt="CleanShot 2026-01-05 at 09 43
56@2x"
src="https://github.com/user-attachments/assets/ce749407-2b7f-4864-9c09-9333c5ac495a"
/>
2026-01-07 14:10:10 +00:00
James Ritchie c7a238c664 Chore(webapp): Queues page UI improvements (#2826)
Very small UI improvement to increase the spacing between the right hand
table columns. Also a tooltip wording improvement.
2026-01-07 14:07:50 +00:00
Eric Allam 8ba7526d51 fix(batch): rate limiting by token bucket no longer incorrectly goes negative (#2837)
Also improves the BatchTriggerError when a result of getting rate
limited.
2026-01-07 14:02:19 +00:00
nicktrn 47bed15255 docs: fix self-hosting section in deploy docs (#2836)
Fixes #2835

There were still some flags in here we removed, deploying is a lot
simpler now for self-hosters.

Also updates the github actions guide.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-06 20:20:20 +00:00
Eric Allam 752ad32293 Add Claude Code GitHub Workflow (#2839) 2026-01-06 19:26:31 +00:00
James Ritchie db0df17a6a chore(webapp): Run navigation UI improvements (#2802)
**Improvements to the run ID copy button and run navigation buttons for
consistency**

- Adds some x-padding and layout adjustment to the copy ID button.
<img width="664" height="114" alt="CleanShot 2025-12-19 at 16 05 21@2x"
src="https://github.com/user-attachments/assets/ebc8e0de-011b-419c-bdcc-eb4157553d1c"
/>

- New custom navigation icons that work better at tiny sizes
<img width="330" height="196" alt="CleanShot 2025-12-19 at 16 06 38@2x"
src="https://github.com/user-attachments/assets/bfd8d6b8-8a65-4eac-9ce1-d70acf0ad265"
/>

Some other small improvements/fixes:
- Fixes a browser html error where there was a <button> inside a
<button>
- Updates the shortcut description to match the tooltip text for
consistency
- Made the hover states more consistent
- The shortcut bar at the bottom snaps to the list sooner because there
are more items now
2026-01-06 18:20:54 +00:00
Eric Allam edf5b142fc fix(fair-queue): Prevent unbounded memory growth from metrics cardinality explosion (#2819) 2025-12-25 08:51:40 +00:00
Eric Allam 4c3dfac223 fix(fair-queue): prevent unbounded cooloff states growth (#2818) 2025-12-24 16:33:38 +00:00
nicktrn 7ccbbdb368 docs: tidy up sync env vars note blocks (#2817) 2025-12-24 13:02:26 +00:00
Eric Allam 71279a7b12 fix(fair-queue): prevent unbounded memory growth by cleaning up queue descriptor and cooloff state cache (#2816) 2025-12-24 10:40:32 +00:00
Eric Allam 2eba36c086 chore(redis-worker): add otel spans to fair queue processing pipeline (#2815) 2025-12-24 00:37:24 +00:00
Eric Allam 29827e96f2 fix(batch): add batch queue back into master queue after visibility timeout (#2814) 2025-12-24 00:25:46 +00:00
Dan d416f340ad Added example projects link (#2812)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯
2025-12-23 18:37:19 +00:00
nicktrn 7a54a843e2 fix(cli): update command should preserve existing package.json order (#2810)
This fixes a regression introduced in #2778 - stable sort is required
for deterministic builds, but we can safely preserve order for the user
package.json during package updates
2025-12-23 12:52:06 +00:00
nicktrn 52e9baede5 fix(webapp): don't rate limit deployment finalization (#2809)
turns out we also had v2 and v3 routes
2025-12-23 09:22:00 +00:00
Eric Allam deb80890fe chore(otel): add spans to the batch queue processing pipeline (#2808) 2025-12-23 08:40:33 +00:00
Eric Allam d82089686c fix(batch): extract the queue name out of an already nested queue option (#2807) 2025-12-22 21:40:14 +00:00
Oskar Otwinowski acc10e847c chore(docs): add more cost saving tips (#2806)
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-12-22 15:36:54 +00:00
Eric Allam f1a83cffc4 chore(docs): debounce options (#2797) 2025-12-22 15:35:51 +00:00
Eric Allam 61fee91830 chore(docs): upgrade for new batch trigger limits and functionality (#2787) 2025-12-22 15:35:43 +00:00
github-actions[bot] caa40ce925 chore: release v4.3.1 (#2788)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/build@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

## trigger.dev@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`
    -   `@trigger.dev/build@4.3.1`
    -   `@trigger.dev/schema-to-json@4.3.1`

## @trigger.dev/core@4.3.1

### Patch Changes

- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))

## @trigger.dev/python@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.3.1`
    -   `@trigger.dev/core@4.3.1`
    -   `@trigger.dev/build@4.3.1`

## @trigger.dev/react-hooks@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

## @trigger.dev/redis-worker@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

## @trigger.dev/rsc@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

## @trigger.dev/schema-to-json@4.3.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

## @trigger.dev/sdk@4.3.1

### Patch Changes

- feat: Support for new batch trigger system
([#2779](https://github.com/triggerdotdev/trigger.dev/pull/2779))
- feat(sdk): Support debouncing runs when triggering with new debounce
options
([#2794](https://github.com/triggerdotdev/trigger.dev/pull/2794))
- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.1`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-22 15:29:18 +00:00
Dan ba9b0e17c1 Added Claude Agent SDK guide and examples (#2803) 2025-12-19 17:10:32 +00:00
Oskar Otwinowski 469808cf09 fix(webapp): Make spans fluid again while task is executing (#2801)
Closes #<issue>

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Tested on executing tasks and on moving between tasks

---

## Changelog

Small UI fix for spans on the task runs page

---

## Screenshots



https://github.com/user-attachments/assets/72095b03-c74f-4472-afde-9c63e6e7c224



💯
2025-12-19 15:45:54 +01:00
Mihai Popescu 7574c69c2d feat(webapp): Add support for resetting idempotency keys (#2777)
Add support for resetting idempotency keys both from ui and sdk

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing
- Created a new run with a idempotency idempotencyKey.
- Started a new run with the same task and got redirected to the first
run.
- Deleted the key from the UI on the run details
- Started a new run with the same task  and it created a new one
- Did the above steps using the SDK


---

## Changelog

- Add new action route for resetting idempotency keys via UI
- Add reset button in Idempotency section of run detail view
- Added API and SDK for resetting imdepotency
- Updated docs page for this feature


---

## Screenshots

_[Screenshots]_
<img width="438" height="363" alt="Screenshot 2025-12-11 at 11 56 37"
src="https://github.com/user-attachments/assets/30b8ef5e-8aac-4d04-b57a-9bf30d085dcb"
/>
2025-12-19 11:55:04 +00:00
Eric Allam 06cbe6e3ca chore(docs): upgrade sentry guide to use new global init and global hooks (#2799) 2025-12-19 00:00:02 +00:00
Eric Allam 3875bb292a feat(engine): run debounce system (#2794)
Adds support for **debounced task runs** - when triggering a task with a
debounce key, subsequent triggers with the same key will reschedule the
existing delayed run instead of creating new runs. This continues until
no new triggers occur within the delay window.

## Usage

```typescript
await myTask.trigger({ userId: "123" }, {
  debounce: {
    key: "user-123-update",
    delay: "5s",
    mode: "leading", // default
  }
});
```

- **key**: Scoped to the task identifier
- **delay**: How long to wait before executing (supports duration
strings like `"5s"`, `"1m"`)
- **mode**: Either `"leading"` or `"trailing"`. Leading debounce will
use the payload and options from the first run created with the debounce
key. Trailing will use payload and options from the last run.

### "trailing" mode overrides

When using `mode: "trailing"` with debounce, the following options are
updated from the **last** trigger:

- **`payload`** - The task input data
- **`metadata`** - Run metadata
- **`tags`** - Run tags (replaces existing tags)
- **`maxAttempts`** - Maximum retry attempts
- **`maxDuration`** - Maximum compute time
- **`machine`**  - Machine preset (cpu/memory)

## Behavior

- **First run wins**: The first trigger creates the run, subsequent
triggers push its execution time later
- **Idempotency keys take precedence**: If both are specified,
idempotency is checked first
- **Max duration**: Configurable via `DEBOUNCE_MAX_DURATION_MS` env var
(default: 10 minutes)

Works with `triggerAndWait` - parent runs correctly block on the
debounced run.
2025-12-18 16:04:43 +00:00
Saadi Myftija ff80742ab7 fix(ci): use workflow_dispatch instead of repository_dispatch for publish.yml (#2791)
Mixed up the trigger in #2790
2025-12-16 16:53:22 +01:00
Saadi Myftija 11366e658c chore(ci): add manual trigger to the image publishing workflow (#2790)
Useful to retry failures manually.
2025-12-16 16:34:57 +01:00
Eric Allam e751f8832e fix(app): incorrectly duplicated env vars for new batch trigger system (#2789) 2025-12-16 15:27:13 +00:00
Eric Allam a999d9ea3f feat(engine): Batch trigger reloaded (#2779)
New batch trigger system with larger payloads, streaming ingestion,
larger batch sizes, and a fair processing system.

This PR introduces a new `FairQueue` abstraction inspired by our own
`RunQueue` that enables multi-tenant fair queueing with concurrency
limits. The new `BatchQueue` is built on top of the `FairQueue`, and
handles processing Batch triggers in a fair manner with per-environment
concurrency limits defined per-org. Additionally, there is a global
concurrency limit to prevent the BatchQueue system from creating too
many runs too quickly, which can cause downstream issues.

For this new BatchQueue system we have a completely new batch trigger
creation and ingestion system. Previously this was a single endpoint
with a single JSON body that defined details about the batch as well as
all the items in the batch.

We're introducing a two-phase batch trigger ingestion system. In the
first phase, the BatchTaskRun record is created (and possibly rate
limited). The second phase is another endpoint that accepts an NDJSON
body with each line being a single item/run with payload and options.

At ingestion time all items are added to a queue, in order, and then
processed by the BatchQueue system.

## New batch trigger rate limits

This PR implements a new batch trigger specific rate limit, configured
on the `Organization.batchRateLimitConfig` column, and defaults using
these environment variables:

- `BATCH_RATE_LIMIT_REFILL_RATE` defaults to 10
- `BATCH_RATE_LIMIT_REFILL_INTERVAL` the duration interval, defaults to
`"10s"`
- `BATCH_RATE_LIMIT_MAX` defaults to 1200

This rate limiter is scoped to the environment ID and controls how many
runs can be submitted via batch triggers per interval. The SDK handles
the retrying side.

## Batch queue concurrency limits

The new column `Organization.batchQueueConcurrencyConfig` now defines an
org specific `processingConcurrency` value, with a backup of the env var
`BATCH_CONCURRENCY_LIMIT_DEFAULT` which defaults to 10. This controls
how many batch queue items are processed concurrently per environment.

There is also a global rate limit for the batch queue set via the
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` which defaults to being disabled. If
set, the entire batch queue system won't process more than
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` items per second. This allows
controlling the maximum number of runs created per second via batch
triggers.

## Batch trigger settings

- `STREAMING_BATCH_MAX_ITEMS` controls the maximum number of items in a
single batch
- `STREAMING_BATCH_ITEM_MAXIMUM_SIZE` controls the maximum size of each
item in a batch
- `BATCH_CONCURRENCY_DEFAULT_CONCURRENCY` controls the default
environment concurrency
- `BATCH_QUEUE_DRR_QUANTUM` how many credits each environment gets each
round for the DRR scheduler
- `BATCH_QUEUE_MAX_DEFICIT` the maximum deficit for the DRR scheduler
- `BATCH_QUEUE_CONSUMER_COUNT` how many queue consumers to run
- `BATCH_QUEUE_CONSUMER_INTERVAL_MS` how frequently they poll for items
in the queue

### Configuration Recommendations by Use Case

**High-throughput priority (fairness acceptable at 0.98+):**

```env
BATCH_QUEUE_DRR_QUANTUM=25
BATCH_QUEUE_MAX_DEFICIT=100
BATCH_QUEUE_CONSUMER_COUNT=10
BATCH_QUEUE_CONSUMER_INTERVAL_MS=50
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=25
```

**Strict fairness priority (throughput can be lower):**

```env
BATCH_QUEUE_DRR_QUANTUM=5
BATCH_QUEUE_MAX_DEFICIT=25
BATCH_QUEUE_CONSUMER_COUNT=3
BATCH_QUEUE_CONSUMER_INTERVAL_MS=100
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=5
```
2025-12-16 14:32:49 +00:00
mpcgird fd46381c8b optimized the animation frame loop 2025-12-15 23:28:27 +02:00
Oskar Otwinowski 28a66ac021 fix(ui): respect rootOnlyDefault, disable adjacent run navigation in inspector views (#2781)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
## Changelog

- Add disableAdjacentRows prop to TaskRunsTable component to control
table state encoding
- Pass rootOnlyDefault prop from loader to TaskRunsTable for proper
state management
- Disable adjacent run navigation in schedule, waitpoint, and other
inspector views
- Preserve adjacent run navigation on main runs list page with rootOnly
filter support
2025-12-12 12:16:29 +00:00
github-actions[bot] 7d34817473 chore: release v4.3.0 (#2770)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## trigger.dev@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))
- feat(cli): enable zstd compression for deployment images
([#2773](https://github.com/triggerdotdev/trigger.dev/pull/2773))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))
- fix(dev): stop max listeners exceeded warning messages when running
more than 10 runs concurrently
([#2771](https://github.com/triggerdotdev/trigger.dev/pull/2771))
- Upgrade @modelcontextprotocol/sdk to 1.24.3
([#2768](https://github.com/triggerdotdev/trigger.dev/pull/2768))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`
    -   `@trigger.dev/build@4.3.0`
    -   `@trigger.dev/schema-to-json@4.3.0`

## @trigger.dev/core@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))

## @trigger.dev/build@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

## @trigger.dev/python@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`
    -   `@trigger.dev/build@4.3.0`
    -   `@trigger.dev/sdk@4.3.0`

## @trigger.dev/react-hooks@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

## @trigger.dev/redis-worker@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

## @trigger.dev/rsc@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

## @trigger.dev/schema-to-json@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

## @trigger.dev/sdk@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-12 13:11:56 +01:00
mpcgird a2277d5a37 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2025-12-12 09:33:13 +00:00
Saadi Myftija 6d6ed471d1 feat(cli): deterministic image builds for deployments (#2778)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
This PR makes our image builds deterministic and reproducible by
ensuring that identical source code always produces the same image
layers and image digest. This means that deployments where nothing has
changed will no longer invalidate the image cache in our worker cluster
nodes, thus avoid making the cold starts for runs worse.

**Context**
New deployments currently increase the cold start times for runs, as
they generate a new image which needs to be pulled in the worker cluster
where runs are executed. It happens also when the source code for the
deployment has not changed due to non-deterministic steps in our build
system. This addresses the latter issue by making builds reproducible.

**Main changes**
- Avoided baking `TRIGGER_DEPLOYMENT_ID` and
`TRIGGER_DEPLOYMENT_VERSION` in the image, we now pass these via the
supervisor instead.
- Used `json-stable-stringify` for consistent key ordering in the files
we generate for the build, e.g., `package.json`, `build.json`,
`index.json`.
- Removed `metafile.json` from the image contents as it is not actually
used in the container. This is only relevant for the `analyze` command.
- Added `SOURCE_DATE_EPOCH=0` and `rewrite-timestamp=true` to Docker
builds to normalize file timestamps.
- Removed some `timings` and `outputHashes` from build outputs and
manifests.

The builds are now reproducible for both native build server and Depot
paths. This should also lead to better image layer cache reuse in
general.
2025-12-12 09:48:04 +01:00
mpcgird b4d6d7859e Fix for mouse hover on a timline not being updates after resize
Refactor MousePositionProvider to improve reactivity during resize, scroll, and layout shifts.
2025-12-11 22:53:49 +00:00
Oskar Otwinowski 7f7f993587 feat(webapp): improve adjacent runs navigation and timeline performance (#2776)
- Add replace prop to LinkButton to use history replacement for adjacent
run navigation
- Preserve span and tab params when navigating between adjacent runs
- Disable animations for completed spans in timeline to improve
performance
- Include spanId in runs list navigation for better context preservation
- Direct link to task test page when filtering by single task with no
runs
- Fix minor styling issue with run friendlyId display padding
2025-12-11 17:26:15 +00:00
Oskar Otwinowski d28707826c feat(webapp): add GitHub onboarding flow to empty Tasks and Deployments pages (#2775)
##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Set up the local github application, and tested its connection with
trigger.dev

Checked:
- Change is backwards compatible
- Actions/ux are uniform across the pages (empty Tasks, Deployments,
project settings)
- Connecting GH, Connecting Repo, disconnecting Repo, modifying settings

---

## Changelog

- Create new resource route for GitHub settings management with loader
and actions
- Add GitHubSettingsPresenter to fetch connected repos and installations
- Implement GitHubSettingsPanel component for reusable GitHub
configuration UI
- Refactor project settings page to use shared GitHubSettingsPanel
component
- Integrate GitHub connection flow into empty state onboarding for Tasks
and Deployments
- Add support for GitHub repo connection, disconnection, and branch
tracking settings
- Include redirect URL support for seamless navigation after GitHub
actions
- Remove duplicate GitHub connection code from project settings route

---

## Screenshots


https://github.com/user-attachments/assets/8fc24699-640b-4f9e-afd8-b26edc945218

🐐

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-10 17:03:16 +00:00
Max Strübing 8b00198f99 docs(kubernetes): use v4 helm chart instead of beta (#2671) 2025-12-10 14:52:32 +00:00
nicktrn 74e9246bfa feat(cli): enable zstd compression for deployment images (#2773)
This will speed up ice cold starts (*) for two reasons:
- better compression ratio
- faster decompression

This is a minor release because zstd compression will now be enabled by
default for all deployments.

(*) ice cold starts happen when deploy images are not cached on the
worker node yet. These cold start durations are highly dependent on
image size and as it turns out, also the type of compression used.
2025-12-10 14:49:44 +00:00
Mihai Popescu 07a31d3732 fix(webapp) : fixed Cmd+Left Arrow was intercepted by the TreeView component (#2772)
Fixed the issue where Cmd+Left Arrow was being intercepted by the
TreeView component on task runs screen.

Solution:
Added a check in the getTreeProps keyboard handler to detect when
metaKey (Cmd on macOS) is pressed with Left Arrow. When detected, the
handler returns early without preventing the default browser behavior,
allowing Chrome's native back navigation to work.


Closes #<issue>

##  Checklist

- [ x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ x] The PR title follows the convention.
- [ x] I ran and tested the code works

---

## Testing

Went to the task details page and confirmed that Cmd + Left Arrow will
navigate back

---

## Changelog

Modified TreeView.tsx to check for e.metaKey before handling Left Arrow
key events
When Cmd+Left is pressed, the event is no longer prevented, allowing
browser default behavior
---

Co-authored-by: Mihai Popescu <mihaipopescu@Mihais-MacBook-Pro.local>
2025-12-10 12:05:16 +00:00
Saadi Myftija da111e220f fix(api): whitelist deployment endpoints from the general API rate limits (#2774)
Deployments are affected by general API rate limits, this is just a
quick fix by whitelisting the deployment related endpoints. In a follow
up PR we'll add a separate rate limiter for this group of endpoints.
2025-12-10 12:03:56 +01:00
Oskar Otwinowski 2c3cb4a43a feat(webapp): UX improvements for TaskRun page and TaskRun table (#2760)
##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Manual testing of the task run pages

---

## Changelog

- Add previous/next run navigation buttons to run detail page header
- Support [ and ] keyboard shortcuts to jump between adjacent runs
- Preserve runs table state (filters, pagination) when navigating
- Preload adjacent page runs at boundaries for seamless navigation
- Add actions prop to PageTitle component
- Document shortcut in keyboard shortcuts panel

- Store current filter state from runs table as `tableState` search
param when navigating to individual run pages
- Restore filters when navigating back from run detail view to runs list
- Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional
searchParams
- Use `useOptimisticLocation` to capture current search params in
TaskRunsTable
- Parse `tableState` param in run detail route and pass filters to back
button

- This improves UX by remembering filter selections (task, status, date
range, etc.) when users click into a run and then navigate back to the
runs list

- Add new text-below variant that shows "Click to copy" tooltip on hover
and "Copied" on click. Also add controlled open/onOpenChange props to
SimpleTooltip for managing tooltip visibility.

---

## Screenshots


https://github.com/user-attachments/assets/5067bbe0-1bcd-4e75-80a7-f56dabd5ed69
2025-12-09 13:10:11 +00:00
Saadi Myftija b71bf89444 chore(releases): minor improvements to the release workflow (#2764)
Changes in this PR:
- Arbitrary refs are now allowed when triggering the release workflow
manually (ref must be on the main branch).
- Release summary is now displayed in the GH job output; makes for a
nicer experience when approving the release workflow.
2025-12-09 11:29:48 +01:00
Eric Allam 28c0c78257 fix(api): triggering a batch on a v4 project via the v3 SDK no longer results in errors (#2752)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Updates `BatchTriggerV3Service` to send `batch.id` (not `friendlyId`)
as `batchId` to `TriggerTaskService.call`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
68bf8df4a29bdcac5bfb2806bc417541f69ffd6e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:39 +00:00
Eric Allam c021d1db63 fix(dev): stop max listeners exceeded warning messages when running more than 10 runs concurrently (#2771)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Centralizes SIGTERM handling in `DevSupervisor` and removes per-run
SIGTERM listeners in `DevRunController` to avoid
MaxListenersExceededWarning under high concurrency.
> 
> - **Dev runtime**:
> - **SIGTERM handling**: Add centralized handler in
`packages/cli-v3/src/dev/devSupervisor.ts` to gracefully stop all run
controllers; unregisters on `shutdown()`.
> - **Cleanup**: Remove per-controller `SIGTERM` listener and handler
from `packages/cli-v3/src/entryPoints/dev-run-controller.ts` to reduce
event listeners and warnings.
> - **Changeset**: Add patch note in
`.changeset/fuzzy-ghosts-admire.md`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5ad2f5341829cebf6fd37a3c616a2db5e4ad936a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:28 +00:00
Eric Allam f62cdfe00e feat(dashboard): login with google and "last used" indicator (#2746)
<img width="568" height="513" alt="CleanShot 2025-12-05 at 14 27 16"
src="https://github.com/user-attachments/assets/1f44d8b9-8791-4b44-96d5-4a0960a1ab36"
/>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Google OAuth login and a cookie-based “last used” indicator on
the login page, with supporting backend, routes, and schema updates.
> 
> - **Auth/Backend**:
> - **Google OAuth**: Integrates `remix-auth-google` via new
`addGoogleStrategy` and enables when `AUTH_GOOGLE_CLIENT_ID/SECRET` are
set (`services/googleAuth.server.ts`, `services/auth.server.ts`).
> - **User handling**: Implements `findOrCreateGoogleUser` with
linking/upsert logic and conflict logging (`models/user.server.ts`).
> - **MFA + session**: Google/GitHub/Magic callbacks now set session,
handle MFA, and set a "last-auth-method" cookie
(`routes/auth.google*.tsx`, `routes/auth.github.callback.tsx`,
`routes/magic.tsx`, `services/lastAuthMethod.server.ts`).
> - **GitHub strategy**: Safer email check
(`services/gitHubAuth.server.ts`).
> - **Routes/UI**:
> - **Login page**: Adds "Continue with Google" button and animated
"Last used" badge based on cookie; keeps GitHub/Email options
(`routes/login._index/route.tsx`).
> - **Redirect safety**: Sanitize redirect paths and persist redirect
via cookies in auth actions (`routes/auth.github.ts`,
`routes/auth.google.ts`).
>   - **Assets**: Adds `GoogleLogo` SVG.
>   - **Avatar**: Set `referrerPolicy="no-referrer"` on profile image.
> - **Config/Schema**:
> - **Env**: Adds `AUTH_GOOGLE_CLIENT_ID`/`AUTH_GOOGLE_CLIENT_SECRET`
(`env.server.ts`).
> - **DB**: Extends `AuthenticationMethod` enum with `GOOGLE` (Prisma
schema + migration).
> - **Dependencies**:
>   - Adds `remix-auth-google` in `package.json`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9f84f974bd6f21f1699c4f69a6aa91616842d1b1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-09 09:51:42 +00:00
nicktrn 66c6da7114 security: dependabot alert triage (#2768)
- Upgrade @modelcontextprotocol/sdk to 1.24.3
- Override jws to 3.2.3
2025-12-08 21:02:13 +00:00
Saadi Myftija 7fba9e9f6b feat(deployments): add build server meta and trigger source info (#2767)
This PR applies a small change to the deployments table to keep track
of:
- where the deployment was triggered from
- build server metadata, if the build server was involved
2025-12-08 16:29:26 +00:00
Saadi Myftija cf63fc9cd2 chore: update version requirement hint in build settings (#2757) 2025-12-08 09:27:28 +01:00
Saadi Myftija d1c3bfb9c9 fix(deployments): ecr repo exists check (#2762)
We recently upgraded the ECR sdk version. Our ECR repo exists check
relies on the type of the error thrown and the new ECR sdk version seems
to have broken that behavior. This PR adds a workaround to the issue.
2025-12-05 19:16:28 +01:00
Saadi Myftija d8f5853457 fix(releases): use contents: write permission in the release workflow (#2761)
Needed to push tags
2025-12-05 17:22:24 +01:00
Saadi Myftija a52566d9cc feat(releases): add a manual trigger for the package release workflow (#2759)
The manual trigger is currently enabled only for the prerelease job.
This PR adds it for the normal release flow too, as it is useful to
retrigger failed release workflows.
2025-12-05 17:00:02 +01:00
Saadi Myftija 07a1d04d52 fix(releases): add missing npm upgrade step (#2758)
Accidentally removed this in a previous PR.
2025-12-05 16:41:45 +01:00
github-actions[bot] 185f4ecaf9 chore: release v4.2.0 (#2713)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## trigger.dev@4.2.0

### Minor Changes

- feat(cli): upgrade bun deployments to v1.3.3
([#2756](https://github.com/triggerdotdev/trigger.dev/pull/2756))

### Patch Changes

- fix(otel): exported logs and spans will now have matching trace IDs
([#2724](https://github.com/triggerdotdev/trigger.dev/pull/2724))
- The `--force-local-build` flag is now renamed to just `--local-build`
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))
- fix(cli): header will always print the correct profile
([#2728](https://github.com/triggerdotdev/trigger.dev/pull/2728))
- feat: add ability to set custom resource properties through
trigger.config.ts or via the OTEL_RESOURCE_ATTRIBUTES env var
([#2704](https://github.com/triggerdotdev/trigger.dev/pull/2704))
- feat(cli): implements content-addressable store for the dev CLI build
outputs, reducing disk usage
([#2725](https://github.com/triggerdotdev/trigger.dev/pull/2725))
- Added support for native build server builds in the deploy command
(`--native-build-server`)
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))
-   Updated dependencies:
    -   `@trigger.dev/build@4.2.0`
    -   `@trigger.dev/core@4.2.0`
    -   `@trigger.dev/schema-to-json@4.2.0`

## @trigger.dev/build@4.2.0

### Patch Changes

- syncVercelEnvVars to skip API and read env vars directly from
env.process for Vercel build environments. New syncNeonEnvVars build
extension for syncing environment variablesfrom Neon database projects
to Trigger.dev. The extension automatically detects branches and builds
appropriate PostgreSQL connection strings for non-production, non-dev
environments (staging, preview).
([#2729](https://github.com/triggerdotdev/trigger.dev/pull/2729))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/core@4.2.0

### Patch Changes

- fix: prevent ERR_IPC_CHANNEL_CLOSED errors from causing an unhandled
exception on TaskRunProcess
([#2743](https://github.com/triggerdotdev/trigger.dev/pull/2743))
- Added support for native build server builds in the deploy command
(`--native-build-server`)
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))

## @trigger.dev/python@4.2.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/build@4.2.0`
    -   `@trigger.dev/sdk@4.2.0`
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/react-hooks@4.2.0

### Patch Changes

- fix: prevent infinite useEffect when passing an array of tags to
useRealtimeRunsWithTag
([#2705](https://github.com/triggerdotdev/trigger.dev/pull/2705))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/redis-worker@4.2.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/rsc@4.2.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/schema-to-json@4.2.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/sdk@4.2.0

### Patch Changes

- fix(sdk): Re-export schemaTask types to prevent the TypeScript error
TS2742: The inferred type of 'task' cannot be named without a reference
to '@trigger.dev/core/v3'. This is likely not portable.
([#2735](https://github.com/triggerdotdev/trigger.dev/pull/2735))
- feat: add ability to set custom resource properties through
trigger.config.ts or via the OTEL_RESOURCE_ATTRIBUTES env var
([#2704](https://github.com/triggerdotdev/trigger.dev/pull/2704))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-12-05 16:27:21 +01:00
nicktrn 0d764d4c46 feat(cli): upgrade bun deployments to v1.3.3 (#2756)
New deployments with `runtime: "bun"` will now use Bun v1.3.3

Link to Bun release: https://bun.com/blog/bun-v1.3.3
2025-12-05 13:31:27 +00:00
Saadi Myftija beb52b9800 chore(releases): adjust gh workflow title (#2755)
Just a tiny workflow label change
2025-12-05 13:33:35 +01:00
Saadi Myftija 3401a1d0a9 feat(releases): require approval for package releases (#2753)
**Background**
Currently the changeset PR creation and the publishing is handled by the
same workflow. This is not ideal:
- The build steps are executed on every run of the pipeline, even though
they're only needed for the publish case.
- The PR creation workflow does not need permissions to publish to npm,
only the release path needs them.
- Adding an approval step is painful as we'd need to also approve each
changeset PR creation workflow run.

**Changes in this PR**
- Separated the changeset PR creation into its own workflow and minimum
permission set.
- Added a GH environment with an approval step for the package
publishing workflow (also for prereleases).
- New publish workflow runs will not cancel in-progress runs; helps
avoid partial failures in publishing.

These changes also enable hardening the npm OIDC setup by tying it to a
GH environment that requires approval.
2025-12-05 13:25:25 +01:00
nicktrn 3f982ed366 docs: update email regex examples (#2749)
The previous examples lacked start and end anchors
2025-12-04 17:58:25 +00:00
Saadi Myftija 4dc956470d fix(deployments): misc fixes for the native build server deployment flow (#2748)
Improved a couple of error messages.

Also fixed an issue with the s2 token caching.
2025-12-04 17:01:47 +01:00
Oskar Otwinowski 7fddadcce9 chore: Improve Vercel/Neon syncEnvVars build extensions docs (#2747)
chore: Improve Vercel/Neon `syncEnvVars` build extensions docs
2025-12-04 16:34:57 +01:00
Oskar 6e038d4d1e chore: Improve Vercel/Neon syncEnvVars extensions docs 2025-12-04 16:32:15 +01:00
Oskar Otwinowski 05b53967ea feat(build): Add NeonDB branch resolution for Vercel preview environments (#2729)
Vercel's NeonDB integration renders database connection environment
variables at runtime, which means Trigger.dev cannot directly sync these
values during the build process. This change adds support for fetching
branch-specific NeonDB connection strings via the Neon API.

feat(build): Add syncNeonEnvVars extension and improve Vercel env var
syncing

Add a new `syncNeonEnvVars` build extension for syncing environment
variables
from Neon database projects to Trigger.dev. The extension automatically
detects
branches and builds appropriate PostgreSQL connection strings for
non-production
environments (staging, dev, preview).

Features of `syncNeonEnvVars`:
- Fetches branch-specific database credentials from Neon API
- Generates all standard Postgres connection strings (DATABASE_URL,
POSTGRES_URL,
  POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants
- Supports custom database name, role name, and env var prefix options
- Skips automatically in Vercel environments (Neon's Vercel integration
handles this)
- Skips for production environments (designed for preview/staging/dev
branches)

Improvements to `syncVercelEnvVars`:
- When running in a Vercel build environment (detected via VERCEL env
var),
values are now read from process.env instead of the Vercel API response
- This ensures the build uses the actual runtime values Vercel provides
- Removed embedded Neon-specific logic (now handled by separate
extension)
- Simplified and cleaned up the extension code

Documentation updates for both extensions with usage examples and
configuration
options.

Closes #2714

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Set up Vercel + Trigger.dev envs, used Vercel's chat-bot-ai template.
2025-12-04 16:26:35 +01:00
Oskar 702f3b4bca chore: Improve changeset message 2025-12-04 16:26:24 +01:00
nicktrn 8fcd93001d chore: insert missing spaces in changeset 2025-12-04 13:42:32 +00:00
Eric Allam b01b8740cc fix(engine): prevent overriding a queue concurrency limit resuming a paused queue (#2745) 2025-12-04 13:33:12 +00:00
Oskar 357aa99309 Add changeset 2025-12-04 14:12:42 +01:00
Oskar 7cbf82a4ae fix: PR feedback, doc improvements for neon/vercel syncEnvVars 2025-12-04 14:07:31 +01:00
Eric Allam e7fec4097f fix(dev): CLI now properly cleans up the store dir on dev CLI exit (#2744) 2025-12-04 13:13:01 +01:00
Eric Allam d279988e38 fix(workers): prevent ERR_IPC_CHANNEL_CLOSED errors from causing an unhandled exception on TaskRunProcess (#2743) 2025-12-04 12:02:30 +00:00
Saadi Myftija 748ae658f7 fix(releases): use npm 11.x for OIDC support in the release workflow (#2742)
Support for OIDC requires npm v11.5.1 or newer.
2025-12-04 12:21:21 +01:00
Oskar Otwinowski 249878ed92 feat(webapp): link to task-specific test page from filtered runs table (#2741)
When viewing runs filtered to a single task, the "Create a test run" and
"Run a test" buttons now navigate directly to the task-specific test
page instead of the generic test page.

This improves UX by pre-populating the test form with the filtered task,
saving users from having to manually select it again.


##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

## Screenshots

<img width="1706" height="1392" alt="image"
src="https://github.com/user-attachments/assets/d8b5d445-73b5-426c-83a4-90ac2a95b955"
/>
2025-12-04 10:56:24 +00:00
Eric Allam 117b1d5a53 chore(dependabot): upgrade next.js for CVE-2025-66478 in d3-chat example project (#2740) 2025-12-04 10:30:37 +00:00
Eric Allam 04173a93b9 fix(replication): detect misconfigered run replication publication and output helpful error messages (#2736)
Add validation for logical replication publication configuration. Helps
diagnose an issue where runs are no longer replicated to clickhouse
because of a configuration issue with the replication publication.

## Problem

The `LogicalReplicationClient` only checked if a publication existed,
not if it was correctly configured. This caused a silent failure where:

- Replication would start successfully
- Transaction boundaries (begin/commit) were received
- **But no actual data changes were replicated**

This happened when a publication existed but:
1. Had no tables associated with it
2. Was missing required actions (e.g., `delete`)

## Solution

Added `#validatePublicationConfiguration()` method that validates:
-  Publication includes the expected table
-  Publication has all required actions configured

When validation fails, error messages include the exact SQL command to
fix the issue:

**Missing table:**
```
Publication 'task_runs_to_clickhouse_v1_publication' exists but has NO TABLES configured. 
Expected table: "public.TaskRun". 
Run: ALTER PUBLICATION task_runs_to_clickhouse_v1_publication ADD TABLE "TaskRun";
```

**Missing actions:**
```
Publication 'task_runs_to_clickhouse_v1_publication' is missing required actions. 
Expected: [insert, update, delete], Current: [insert, update], Missing: [delete]. 
Run: ALTER PUBLICATION task_runs_to_clickhouse_v1_publication SET (publish = 'insert, update, delete');
```

This prevents silent data loss and makes debugging configuration issues
much easier.
2025-12-04 10:29:13 +00:00
Saadi Myftija da4c753b68 fix(releases): add missing GH token in prerelease workflow (#2739)
The `changeset version` command needs the GH token too.
2025-12-04 11:27:11 +01:00
Eric Allam 6ae3b69745 chore(cursor): add deslop command (#2722) 2025-12-04 10:24:09 +00:00
Saadi Myftija 652d95c7eb feat(releases): add a prerelease workflow (#2737)
Adds a manual trigger to the `release.yml` workflow for publishing
prerelease versions. Needs to be in the same workflow file due to a NPM
limitation on how OIDC claims are checked.

Currently there is a validation step on the ref for the prerelease: it
must be merged to the main branch. We can revisit this in the future in
case we find it too limiting.
2025-12-04 10:17:05 +01:00
Saadi Myftija 341e27d213 chore(deployments): lazily update ECR repo cache settings (#2734)
To avoid doing a migration for the ECR repo cache settings, we lazily do
it on the next deployment for that project. Failures to update the repo
settings are just logged and will not cause the deployment to fail.
2025-12-03 17:15:59 +01:00
Eric Allam 9821d02af7 fix(sdk): Re-export schemaTask types to prevent the TypeScript error TS2742 (#2735)
Fixes this type of error when exporting a `schemaTask` in a monorepo:

```
error TS2742: The inferred type of 'helloWorldSchema' cannot be named without a reference to '@trigger.dev/core/v3'. This is likely not portable.
```
2025-12-03 16:12:23 +00:00
Saadi Myftija 255a73a2fe feat(deployments): --native-build-server support for the deploy command (#2702)
This PR adds support for CLI deployments using the native build server.

**Background**

The deployment command currently does the following:
- bundles the code
- submits the build context to our external build provider and waits for
the build
- triggers deployment state transitions using the platform API

Upstream build provider outages cause issue with deployments,
potentially blocking deployments entirely. We recently introduced the
`--force-local-build` flag as a fallback to enable deployment without a
dependency on the upstream build provider, though it requires users to
have docker in their systems. This PR continues that work by providing a
remote build path which uses our own build server and does not rely on
the external provider.

**Changes in this PR**

Introduced the new `--native-build-server` flag, which does the
following:
- scans all files relevant for the Trigger deployment and evaluates
ignore rules
- packages it up in an archive and uploads it as a deployment artifact
- queues the deployment and triggers the build
- streams logs from the build server

This no longer relies on external build services. Also deployment state
transitions happen on the server-side, giving us more flexibility to
evolve the flow and schemas of related deployment API endpoints. In
general it gives us better control of the whole build and deployment
process. This path will eventually become the default.

The `--detach` flag is also new, allowing to trigger deployments without
waiting for the result.

The deployment artifacts are uploaded via pre-signed URLs to avoid
unnecessary load on the platform. The new `/artifacts` endpoint
generates the pre-signed URLs; size limits are enforced on s3. This
endpoint is deliberately generic, we could extend it in the future to
upload other artifacts client-side in a similar way, e.g., large payload
packets.
2025-12-03 16:40:21 +01:00
Saadi Myftija c5f7a8daf7 fix(releases): explicitly use no frozen lockfile in the install step (#2733) 2025-12-03 16:28:06 +01:00
Saadi Myftija 8b0f51b317 chore(releases): automatically update the lockfile in changeset PRs (#2732)
This is a step which we currently need to do manually and it's rather
painful. The lockfile update is necessary due to cross references in our
packages.

Added it as a separate job instead of a step to start from fresh
workspace, as the state that the `changeset` step leaves the workdir is
not explicitly clear to the reader.
2025-12-03 16:04:30 +01:00
Oskar 53f21e1330 feat(build): Add syncNeonEnvVars extension and improve Vercel env var syncing
Add a new `syncNeonEnvVars` build extension for syncing environment variables
from Neon database projects to Trigger.dev. The extension automatically detects
branches and builds appropriate PostgreSQL connection strings for non-production
environments (staging, dev, preview).

Features of `syncNeonEnvVars`:
- Fetches branch-specific database credentials from Neon API
- Generates all standard Postgres connection strings (DATABASE_URL, POSTGRES_URL,
  POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants
- Supports custom database name, role name, and env var prefix options
- Skips automatically in Vercel environments (Neon's Vercel integration handles this)
- Skips for production environments (designed for preview/staging/dev branches)

Improvements to `syncVercelEnvVars`:
- When running in a Vercel build environment (detected via VERCEL env var),
  values are now read from process.env instead of the Vercel API response
- This ensures the build uses the actual runtime values Vercel provides
- Removed embedded Neon-specific logic (now handled by separate extension)
- Simplified and cleaned up the extension code

Documentation updates for both extensions with usage examples and configuration
options.
2025-12-03 16:03:41 +01:00
Saadi Myftija 331882f59c chore(releases): add package version in changeset PRs (#2730)
* Add the release version to changeset PRs

* Add missing id-token permission, needed for oidc

* Remove a couple of unnecesary steps

* Reference the `changeset-release/main` branch explicitly
2025-12-03 14:42:40 +01:00
nicktrn 1276491a83 docs: clarify slack channel on pro (#2731) 2025-12-03 13:23:38 +00:00
Eric Allam 5b7dfe23b5 feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage (#2725)
* feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage

* fix a few things
2025-12-03 10:17:35 +00:00
Oskar 49b2f683f4 feat(build): Add NeonDB branch resolution for Vercel preview environments
Vercel's NeonDB integration renders database connection environment
variables at runtime, which means Trigger.dev cannot directly sync
these values during the build process. This change adds support for
fetching branch-specific NeonDB connection strings via the Neon API.

Changes:
- Discover NEON_PROJECT_ID from incoming Vercel environment variables
- Call NeonDB API to search for branches matching the git branch name
- Filter branches to find exact matches with Vercel environment prefix
  (e.g., "preview/branch-name") to avoid false positives from partial
  string matches
- Retrieve branch endpoints and select the write endpoint (or first
  available)
- Build connection strings (DATABASE_URL, POSTGRES_URL, etc.) using
  the branch endpoint host while preserving user/password credentials

Safety measures for non-production environments:
- Filter out all Neon-related env vars (DATABASE_URL, PGHOST, etc.)
  before calling the Neon API to prevent accidental use of production
  database credentials
- Only add branch-specific database env vars if a matching Neon branch
  is found and the API call succeeds
- If neonDbAccessToken is not provided or the API fails, non-production
  environments will not receive any database connection env vars

Usage:
Users must provide a NEON_ACCESS_TOKEN (via options or env var) to
enable automatic branch resolution for preview deployments. Production
environments continue to use Vercel's standard env var sync without
modification.
2025-12-02 19:52:45 +01:00
nicktrn af9b3e1c99 fix(cli): header will always print the correct profile (#2728) 2025-12-02 17:45:35 +00:00
Eric Allam df4ab97d59 fix(otel): fix broken schedule run spans (#2727)
schedule spans can sometimes show as generic spans when using the 
task_events_v2 table because of the inserted_at filter. Increasing the 
buffer for the start time does the trick and doesn’t cause any perf 
Issues (and is in general just more robust)
2025-12-02 17:16:08 +00:00
Eric Allam 9f27422472 fix(otel): exported logs and spans will now have matching trace IDs (#2724)
When using custom OTLP exporters via `telemetry.exporters` and 

This occurred when tasks were triggered **without** a parent trace 
context (e.g., via API or dashboard). In this scenario: - Spans were 
correctly rewritten to use the generated `externalTraceId` - Logs kept 
their original internal trace ID due to a bug in the early return logic

### Root Cause

In `ExternalLogRecordExporterWrapper.transformLogRecord()`, the early 
return condition incorrectly included `!this.externalTraceContext`:

```typescript
if (!logRecord.spanContext || !this.externalTraceId || 
!this.externalTraceContext) {   return logRecord;  // Bug: Returns early
when externalTraceContext is undefined }

// This fallback logic was never reached:
const externalTraceId = this.externalTraceContext
  ? this.externalTraceContext.traceId
  : this.externalTraceId;
```

### Fix

1. **Reordered logic in `transformLogRecord()`**: Move the 
1. `externalTraceId` calculation before the early return, and check the 
1. culated value instead of `this.externalTraceContext`:

```typescript
const externalTraceId = this.externalTraceContext
  ? this.externalTraceContext.traceId
  : this.externalTraceId;

if (!logRecord.spanContext || !externalTraceId) {
  return logRecord;
}
```

2. **Clarified `_isExternallySampled` logic**: Updated both 
2. `ExternalSpanExporterWrapper` and `ExternalLogRecordExporterWrapper` 
2. explicitly handle the case where there's no external trace context 
2. a generated `externalTraceId` exists:

```typescript
this._isExternallySampled = externalTraceContext
  ? isTraceFlagSampled(externalTraceContext.traceFlags)
  : !!externalTraceId;
```

### Impact

Logs and spans from the same task run will now have matching trace IDs 
when exported to external observability tools, enabling proper trace correlation regardless of whether the task was triggered with or without a parent trace context. 
`telemetry.logExporters` in `trigger.config.ts`, logs and spans were 
exported with **different trace IDs**, breaking trace correlation in
external observability tools like Datadog.
2025-12-02 14:16:06 +00:00
nicktrn 2f1a72b109 security: remedy dependabot alerts (#2723)
* security: override js-yaml

* security: upgrade vite

* security: update nodemailer
2025-12-02 12:02:48 +00:00
Eric Allam 3c326a4b4a fix(clickhouse): ensure start_time is never older than X ms to prevent old partition merge issues (#2721) 2025-12-01 15:43:10 +00:00
Saadi Myftija 6ae1317b69 chore(clickhouse): enable dropping run debug events (#2720)
Added a new env var (`EVENT_REPOSITORY_DEBUG_LOGS_DISABLED`) that allows disabling writing run debug logs in the event repository.
2025-12-01 15:42:35 +01:00
Eric Allam 2e1c4f6df6 fix(clickhouse): partition by insertion date to prevent "Too many parts" errors when partitioning by start time (#2719) 2025-12-01 12:01:06 +00:00
nicktrn 2bf86dc20e fix(supervisor): image builds with pnpm v10 (#2718) 2025-12-01 11:28:05 +00:00
nicktrn 485782cae1 feat(ch): optionally disable migrations (#2715) 2025-11-28 19:39:29 +00:00
Eric Allam 61b338bea7 chore(repo): upgrade repo to pnpm@10 to prevent executing dep scripts on install (#2712)
* chore: migrate pnpm lockfile to v9 format via pnpm@9

* Upgrade to pnpm 10.23.0

* update the dockerfile and added a few deps to bundle in remix app
2025-11-27 16:26:19 +00:00
Kim Hallberg 83ddf721a4 docs: update Firecrawl example (#2652)
Update the example to use Firecrawl v2 API
2025-11-24 14:54:51 +00:00
Kim Hallberg a4dd2562d2 docs: format embedding example (#2656) 2025-11-24 14:51:53 +00:00
Felipe Martinez Albeche 5ff21a758c fix(docs): tooltip color contrast accessibility (#2662)
* fix(docs): color contrast accessibility on tooltip

* add changeset

* Delete .changeset/tough-feet-accept.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2025-11-24 14:39:14 +00:00
Lindsey 47a64c0335 docs: improve env var documentation with .env upload + ctx environment details (#2680) 2025-11-24 14:37:04 +00:00
Eric Allam fc351cb6c4 chore(repo): remove GITHUB_TOKEN requirement for publishing prerelease packages (#2679) 2025-11-24 14:33:40 +00:00
Eric Allam c4f2a9d065 chore(references): added prisma-generator-ts-enums to prisma ref project as an example (#2701) 2025-11-24 14:33:22 +00:00
Eric Allam 2762c542c2 chore(repo): remove format script to AI stops calling it (#2703) 2025-11-24 14:33:14 +00:00
Eric Allam 72e286af2f feat(otel): support for custom resource attributes via config#telemetry.resource and OTEL_RESOURCE_ATTRIBUTES env var (#2704) 2025-11-24 14:33:07 +00:00
Eric Allam f7240a99e7 fix(react): prevent infinite useEffect when passing an array of tags to useRealtimeRunsWithTag (#2705) 2025-11-24 14:32:57 +00:00
github-actions[bot] 6e47377766 chore: Update version for release (#2700)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-24 10:30:01 +00:00
nicktrn a8563ca534 fix(docker): support the latest docker version (#2686) 2025-11-21 15:24:31 +00:00
Eric Allam 5e5c97ea4c fix(cli): stop dev runs stuck in dequeued status (fix #2639) (#2699)
* stop deleting the first dev version files on the first change, prevents system failures

* prevent dev runs getting stuck in dequeued status by deleting workers

* add changeset
2025-11-21 15:24:07 +00:00
Matt Aitken abd99aa27f Don't show (or count) archived branches/projects against allocated concurrency (#2698)
Archived preview branches were being counted. Also archived projects.
2025-11-20 11:41:12 +00:00
Eric Allam 4347499799 fix(api): Fix preview branch targeting in environment variable API routes (#2697)
This PR fixes the `x-trigger-branch` header support for targeting specific preview branches when managing environment variables. The header was documented but not actually being extracted or used in the environment variable API routes. Additionally, the query logic in `authenticatedEnvironmentForAuthentication` was fundamentally broken—it searched for environments with both `slug: "preview"` (parent environment property) AND a specific `branchName` (child environment property), which no environment could satisfy simultaneously. The fix extracts the branch name using `branchNameFromRequest()` and correctly queries for child branch environments using `type: "PREVIEW"` and the specific `branchName`. This ensures that environment variable operations (create, update, get, list) properly target individual preview branches instead of affecting all preview environments.
2025-11-19 18:01:12 +00:00
Eric Allam abee783d3f chore(logs): remove unnecessary debug logs (#2696) 2025-11-19 13:56:15 +00:00
Eric Allam 6464eeed53 fix(webapp): correctly generate JWT tokens for preview branches after triggering a run (fix #2678) (#2695) 2025-11-19 13:42:12 +00:00
Matt Aitken bee59de3a0 Concurrency self serve (#2681)
* Don't use the organization max concurrency anymore

* Early draft of the concurrency page

* WIP adding a new stepper input component

* Move stepper to be alphabetical

* When max value is reached, disabled the + button

* Show placeholder if you delete all numbers

* Make all the html input values available to the component

* Adds size variants

* Move stepper into its own component

* Work on showing the extra concurrency

* The purchase form styling and functionality (minus actually purchasing)

* New style for outline input fields

* Concurrency purchasing working

* Purchasing concurrency and quota emails working

* Improvements to the modal

* Show cost breakdown in the modal

* Fix for allocated concurrency including DEV

* Improved types

* Allocating concurrency is working

* Live updates total env concurrency

* Implemented reset

* Fix for concurrency allocation editing across multiple projects

* Tabular numbers

* Added an error from allocating concurrency

* Fixes for allocating concurrency where it didn't calculate correctly

* "Increase limit" link to concurrency page

* Indent environments

* Added Preview limit when updating concurrency for an org

* Show error when changing plan fails

* Added maximumProjectCount column to Org

* Limit project count and display a rich error toast (with title and button now)

* Added title and button to toasts. Use it for new project error

* @trigger.dev/platform 1.0.20

* Allow submitting zero concurrency so you can downgrade back to nothing

* Use the server as the truth for omitted environments

* Updated the pricing panels

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-11-19 11:22:23 +00:00
Eric Allam 3af7303156 feat(docs): update prismaExtension for new mode functionality in 4.1.1 (#2690) 2025-11-19 11:16:35 +00:00
github-actions[bot] 6231ddc67a Release v4.1.1
* chore: Update version for release

* chore: Update pnpm-lock.yaml (#2694)

* Initial plan

* Update pnpm-lock.yaml

Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>
2025-11-19 11:09:26 +00:00
Eric Allam 15fef916f6 feat(build): update prisma extension to work with generated clients and rust-free clients (#2689)
* prisma extension fixes WIP

* More prisma stuff

* more prisma stuff

* remove changelog

* upgrade github workflows to use node 20.19 because installing prisma@7 breaks with lower versions

* Don't use generate for the prisma reference projects

* make sure it works if no mode is passed in
2025-11-19 10:48:15 +00:00
Eric Allam e2a703bfa8 fix(dashboard): continuously apply log filter during live run (#2692) 2025-11-18 17:00:29 +00:00
Saadi Myftija 1a7ee24b9e fix(ui): refresh deployments logs stream on status change (#2688)
The logs stream is created after the deployment moves from `PENDING` status to `INSTALLING`.
2025-11-17 14:50:05 +01:00
Saadi Myftija bb99af52cb feat(deployments): expose native build server option in build settings (#2685)
Adds a build setting about using our build server for remote builds.
2025-11-17 12:46:57 +01:00
Saadi Myftija 01797a1668 feat(deployments): ECR repo adaptations to enable external build cache (#2684)
* Update aws sdk ecr client to the latest version

* Exlude the cache tag from the immutability enforcement

* Attach a policy to ECR repos to expire untagged images

* Fix filterType
2025-11-17 12:34:54 +01:00
Eric Allam 19fa669318 Release v4.1.0 (#2683) 2025-11-14 09:55:01 +00:00
github-actions[bot] 78fcba518a chore: Update version for release (#2666)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-14 09:52:22 +00:00
Saadi Myftija 53047ab648 feat(deployments): external cache support for local builds (#2682)
* Add registry cache support for local builds

* Add changeset
2025-11-14 10:46:31 +01:00
Eric Allam bb5cefa92c docs: realtime streams v2 (#2673) 2025-11-14 09:42:20 +00:00
Eric Allam b8b198579c chore(rules): upgrade rules for 4.1.0 (#2676) 2025-11-14 09:42:15 +00:00
Eric Allam 892bed8c4c Upgrade to electricsql 1.2.4 (#2668) 2025-11-13 15:19:59 +00:00
Eric Allam a94a11f44d feat(sdk): replace onStart lifecycle hook with onStartAttempt (#2515)
* fix(sdk): prevent uncaught errors thrown onSuccess, onComplete, and onFailure hooks to fail attempts & in some cases runs

* Add onStartAttempt hook and deprecate onSuccess

* Add onStartAttempt hook and deprecate onStart hook

* Fix onStartAttempt overload types

* Update lifecycle functions diagram
2025-11-13 14:51:13 +00:00
Matt Aitken f116e93e01 Docs: tags can be up to 128 chars (#2678) 2025-11-13 14:43:31 +00:00
Eric Allam 6137338da9 feat(streams): make v2 streams the default when using 4.1.0+ if they are supported (#2677) 2025-11-13 13:53:42 +00:00
Lindsey 8cec3b763b docs: add section on using .env.production and dotenvx for environmen… (#2674)
* docs: add section on using .env.production and dotenvx for environment variables

* fix: correct dotenvx API usage in documentation example
2025-11-13 11:28:26 +00:00
Eric Allam 343ba54c69 fix(streams): restore realtime stream writing for v3 tasks (#2675) 2025-11-13 11:21:48 +00:00
Eric Allam a70ab10809 fix(streams): fixed broken wrapping in streams inspector (#2672) 2025-11-12 16:00:54 +00:00
Eric Allam f7cb637b32 fix(streams): scope s2 access token to environment and fix streams v1 appends (#2670)
* fix(streams): scope s2 access token to environment and fix streams v1 appends

* Less stale time
2025-11-12 12:34:15 +00:00
Eric Allam 668559ec1a fix(streams): buffer v1 streams on read to prevent split chunks (#2669) 2025-11-11 21:08:49 +00:00
Eric Allam d0ad38d684 chore(docker): remove unused seed copy from dockerfile (#2667) 2025-11-11 15:13:10 +00:00
Eric Allam 536d9fa217 feat(realtime): Realtime streams v2 (#2632) 2025-11-11 14:54:00 +00:00
github-actions[bot] d75c3aeadd chore: Update version for release (#2665)
* chore: Update version for release

* Release 4.0.7

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-11-11 14:29:24 +00:00
Matt Aitken a342332146 Fix for the MCP tool that gets logs for debugging runs (#2653)
* Fix for the MCP tool that gets logs for debugging runs

This was broken when we changed the data on the backend that returns 
log/span data from runs. We changed the data structured and the internal
API that the MCP client uses was failing to parse with the Zod schema

* add changeset

* Revert "add changeset"

This reverts commit 86eca836d5907fa0d0f8ac595d4d5ebade140514.

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-11-11 13:26:07 +00:00
Saadi Myftija 9624465ee2 fix: error handling issue with s2 streams (#2664)
* Fix logger import

*old man yells at auto-import*

* Fix s2 error handling for non-existing streams
2025-11-11 09:44:54 +01:00
Saadi Myftija 42f53b12b7 feat(deployments): stream build server logs (#2663)
* Use read-only project-scoped s2 tokens for streaming deployment logs

* Add http2 to remix polyfills

Needed for using s2 client-side.

* Stream build-server logs in the deployment details page

* Disable 12-hour format in the DateTime component

* Enable collapsing the logs panel

* Auto-collapse logs for succesful/timedout/queued deployments

* Make S2 env vars optional

* Show the logs section only for gh-triggered deployments

* Cache s2 access tokens in redis

* Reset streaming state

* Expose 12h format as a param for the Datetime components
2025-11-10 12:42:40 +01:00
github-actions[bot] 9fdf91a1c4 Release v4.0.6 (#2647)
* chore: Update version for release

* Update lock file

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: myftija <saadi.myftija@gmail.com>
2025-11-03 14:42:02 +01:00
nicktrn 9593a46364 fix(extensions): prevent audiowaveform binary removal (#2643)
* fix(extensions): prevent audiowaveform binary removal

* add changeset
2025-11-03 14:17:38 +01:00
Saadi Myftija 27376df903 fix(cli): show a useful error message when config file is missing (#2650)
* fix(cli): show a useful error message when config file is missing

* Add changeset
2025-10-31 16:34:48 +01:00
Saadi Myftija e0cece72ae docs: deployments with local builds (#2645)
* docs: deployments with local builds

Adds a section to the deployment docs page about the option to deploy using a locally built image.

* Update installation link
2025-10-30 18:10:23 +01:00
Saadi Myftija 2f3f82f3a9 feat(deployments): show local build hint during depot outages (#2646)
* Add an API endpoint to query remote build provider status

* Show local build hint for failed deployments when Depot is down

* Show the local build flag in the help output

* Add changeset

* Fix import

* Fix docs link
2025-10-30 18:09:54 +01:00
Dan 4264bdf429 Added use cases section in the docs (#2641)
* Started adding use cases pages

* Updates

* More improvements

* Improved diagrams

* Added overview page

* More copy + diagram updates

* Improved diagram titles

* Further diagram improvements

* Corrected workflow

* Updated copy

* Typos

* Updated intro file

* Reverted aiRunFilterService.server.ts
2025-10-30 09:21:31 +00:00
nicktrn bf6735bd56 fix(webapp): concurrency override upper bound should be env not org (#2642) 2025-10-29 19:16:42 +00:00
James Ritchie dacd53b906 Fix for showing the incident UI in the side menu (#2638) 2025-10-28 21:13:23 +00:00
github-actions[bot] d4fe71df34 Release v4.0.5 (#2531)
* chore: Update version for release

* Release v4.0.5

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-10-28 14:20:20 +00:00
nicktrn ae8e83b2d0 chore(runner): move max duration logic into parent process (#2637)
* chore(runner): move max duration logic into parent process

* chore(rsc): remove type-marker package.json

* add changeset

* chore(core): remove irrelevant test after our changes

* chore(core): clarify we don't care about the timeout promise
2025-10-28 13:49:59 +00:00
nicktrn 2283ca6ad3 fix(webapp): persist concurrency overrides on deploy (#2636)
* fix(webapp): display correct concurrency override base value

* fix(webapp): persist concurrency overrides on deploy

* fix(webapp): use correct override base value type

* fix(webapp): override input is bounded by env concurrency
2025-10-28 11:28:38 +00:00
nicktrn 8fdbbeb02f chore(helm): increase default clickhouse resources (#2635) 2025-10-27 13:45:09 +00:00
Marcus Nerløe 255ea0a4b3 fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler (#2627)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
* fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler

* fix: added catch handler for informer.start() failures

* fix: removed 'errorStack' from error log
2025-10-23 11:13:45 +01:00
Saadi Myftija 7f25e82299 feat(deployments): support local builds in cloud (#2628)
* Enable skipping image push during deployment finalization step

* Add endpoint to generate registry credentials for a deployment

* Add a --force-local-build flag to the deployment command to skip remote build

* Do not show the new flag in the help output

* Add changeset

* Remove registry login logs from onLog, not useful

* Rename var

* Update platform package to the latest version
2025-10-23 10:57:36 +02:00
Eric Allam d90da7abf7 fix(replication): allow disabling of task run payload inserts via env var (#2626) 2025-10-22 11:22:14 +01:00
Matt Aitken 2affe541e8 Dev concurrency limit by env (with optional global limit) (#2625)
* Limit local dev concurrency using the dev environment concurrency limit

Previously it was limited to max of 25, no matter the environment limit

* Have global dev limit
2025-10-22 10:55:05 +01:00
Matt Aitken 3157b657c7 Test page recent payload: use ClickHouse to get the latest run ids (#2614)
The Postgres query to get the latest run ids for the test page was very 
slow when there were a lot of runs and/or versions.

This now uses the standard runs list we use everywhere else.
2025-10-17 17:17:36 +01:00
Matt Aitken 41bdab58d5 Document the dev --analyze command (#2613) 2025-10-17 13:00:30 +01:00
James Ritchie fe3fe01fe8 feat(queues): Override queue concurrency limits from the dashboard or API (#2609)
* feat(queues): add ability to override concurrency limit via API and dashboard

* Updates the modal layout and tweaks copy

* Improves the dropdown menu item

* Popover supports both Button and LinkButton

* Right align the columns and fix the dropdown menu item styles

* Organize imports,

* Fix spinner icon in dropdown menu

* Remove unused props

* Adds a tooltip to the Concurrency override badge

* Fixes console error with popover menu

* typo

* Fixes incorrect className

* Minimal buttons to view runs

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2025-10-17 12:58:25 +01:00
Matt Aitken 68d0037e60 fix(dev): dotenv issues when getting setup with the repo (#2612)
- The .env.example was missing required Clickhouse values.
- A symlink was needed for .env from the root to the apps/webapp folder
2025-10-17 11:49:40 +01:00
Matt Aitken 885d2d3560 Tags listing now uses ClickHouse (#2576)
* WIP using ClickHouse for the tags filter list

* WIP on tags listing

* Webapp: exclude test files when typechecking

* Tags filtering working with CH

* Remove unused import

* The AI filter should only look at the last past 30d of tags

* Do the text query in ClickHouse

* Deal with encoded characters better

* More encoding fixes

* Fix for wrong items being checked

* Put applied tags back

* Add the env.id to the dependencies array
2025-10-15 12:56:08 +01:00
Eric Allam a445af1b79 feat(otel): allow clickhouse task events to be inserted using async_insert via env vars (#2608) 2025-10-15 10:37:17 +01:00
nicktrn 5781783c74 fix(engine): default to paid placement on billing errors (#2604)
* chore(billing): improve logs to distinguish between failure modes

* fix(engine): default to paid placement on billing errors

* chore(engine): set plan type according to paying field when missing
2025-10-15 10:32:27 +01:00
nicktrn cca10c22d3 chore(supervisor): add machine label (#2603) 2025-10-15 10:31:37 +01:00
Leonardo Kaynan 63b6fc93fa fix(helm): align values-production-example.yaml with values.yaml (#2606)
- Move S3 credentials from secrets.objectStore to s3.auth
- Update external PostgreSQL config to use databaseUrl/directUrl approach
- Add existingSecret support for PostgreSQL with secretKeys
- Add TLS configuration for external Redis
- Add existingSecret support for Redis, ClickHouse and S3
- Add complete external S3 configuration example
- Improve secure credential management documentation

These changes align the production example file with the current values.yaml
structure, making it easier to configure external services with better
support for secret management.
2025-10-14 23:16:58 +01:00
Saadi Myftija aa66462971 docs(build-server): adjust build env vars section (#2605)
Adds a clarification about the env var prefix stripping.
2025-10-14 17:29:42 +02:00
James Ritchie f6461684ad chore(webapp): adds shortcut key for admin area (#2570)
* Allow shortcuts hook to work if undefined

* Conditionally show shortcut button if only 1 result

* LinkButton can accept conditionally shown shortcuts
2025-10-13 13:26:00 +01:00
James Ritchie a6896b411a Adds a link to edit the profile icon from the main menu (#2572) 2025-10-13 13:25:06 +01:00
Saadi Myftija 0cabbdd31f docs: deploying using the github integration (#2598)
* docs: deploying using the github integration

* Add hint in the gh actions docs page

* Remove extra space

* Add a couple of hints to the build config fields
2025-10-13 14:15:48 +02:00
Eric Allam f8977a7b70 chore(db): remove unnecessary FK constraints on TaskRunExecutionSnapshot (#2533) 2025-10-09 14:07:45 +01:00
Saadi Myftija f0643f76f5 feat(build-server): add option to specify pre-build command (#2596)
* feat(build-server): add option to specify pre-build command

Adds an option to specify a pre-build command in the build settings. Can
be useful for projects that need a step before the build, e.g., to
generate a prisma client.

Also, remove the install directory in favor of simplicity. Both
pre-build and install commands are run from the root of the repo. Users
that need to run the commands in a different dir can just prepend to the
command, e.g., `cd apps/web && pnpm run primsa:migrate`

* Fix spelling
2025-10-08 17:25:44 +02:00
Saadi Myftija 416dbcd536 fix(webapp): disable gh-triggered preview deployments if the preview env is disabled (#2595)
* Show hint if preview branches are disabled in the project

* Enable preview deployments only if the preview environemtn is enabled

* Fix prisma reference
2025-10-08 16:59:25 +02:00
Eric Allam 679b41dc7e chore(electric): upgrade server to 1.1.14 (#2590) 2025-10-08 14:33:10 +01:00
Eric Allam be98aecbfd fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors (#2594)
* fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors

* only check parts of the string that are not going to get truncated
remove unnecessary taks
2025-10-08 14:32:35 +01:00
nicktrn 129dc02f2a chore: add deepwiki badge to main readme (#2587)
This means DeepWiki will automatically re-index our repo and keep our page fresh
2025-10-08 13:15:06 +01:00
nicktrn 8917478d3c fix(runner): SIGTERM handling during warm start long poll (#2593) 2025-10-08 12:51:10 +01:00
Leonardo Kaynan f5caa66348 fix(helm): use bitnami legacy repo for minio console (#2592)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* fix(charts): use bitnamilegacy repo for MinIO Object Browser

The Bitnami `minio-object-browser` image has been removed from Docker
Hub. This patch updates the Trigger Helm chart to reference the
`bitnamilegacy/minio-object-browser` repository under the
`s3.console.image` section, ensuring that the legacy MinIO browser pod
can still be deployed without image pull errors.

All other MinIO components continue using the `bitnamilegacy/minio`
image for consistency across Bitnami Legacy dependencies.

* chore(helm): bump Helm chart version to 4.0.4
2025-10-07 22:17:37 +01:00
Eric Allam 64fcc88fa7 fix(otel): spans with entities (like waitpoints) now correctly returned from clickhouse repo (#2591) 2025-10-07 16:55:00 +01:00
nicktrn 23dbe282ed security: upgrade transitive axios dep (CVE-2025-58754) (#2589) 2025-10-07 14:56:16 +01:00
Eric Allam 692316e82a fix(realtime): Upgrade to @electric-sql/client@1.0.14 to prevent cached 409 Conflict errors from breaking realtime updates (#2588) 2025-10-07 14:26:03 +01:00
Saadi Myftija 107f4dc87c fix(deployments): retry transient depot build init failures (#2586)
The Depot build init with `depot.build.v1.BuildService.createBuild` fails surprisingly often due to transient errors, causing the whole deployment to fail. This PR adds a simple retry mechanism with backoff using p-retry. This should improve the failure rate.
2025-10-06 11:33:31 +02:00
Eric Allam b90f3e2173 fix(otel): remove clickhouse event repo feature flag support from v3, now v4 only (#2585) 2025-10-06 09:54:23 +01:00
Eric Allam b3b2553651 fix(otel): propagate the task event store to run descendants (#2583) 2025-10-04 07:28:16 -07:00
Eric Allam cdd1a8838c fix(otel): prevent spans with negative durations (#2582) 2025-10-03 06:50:16 -07:00
Eric Allam 200b7354d0 fix(otel): clickhouse logs/span metrics now exclude partials and debug events (#2581) 2025-10-02 13:51:50 -07:00
Eric Allam eeed38d223 fix(clickhouse): correctly format datetime64(9) input format (#2580) 2025-10-02 11:32:32 -07:00
nicktrn 0ca092651b feat(supervisor): optional custom scheduler (#2579) 2025-10-02 16:22:53 +01:00
Eric Allam 53acdf8ef5 fix(otel): don't pass isDebug when creating postgresql task events (#2578) 2025-10-01 21:57:35 -07:00
Eric Allam 128bc437f6 feat(otel): Add support for storing run spans and log data in Clickhouse (#2567) 2025-10-01 12:41:18 -07:00
James Ritchie 0597691001 Adds 200 and 500 % billing alert options (#2571) 2025-09-30 13:59:44 -07:00
mintlify[bot] dae84a0d29 Update docs/idempotency.mdx (#2575)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-09-30 13:50:02 -07:00
nicktrn f72d63aac2 chore(helm): migrate to bitnami legacy registry and add configurable utility images (#2574)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* chore(docker): use bitnami legacy repo

* chore(helm): use bitnami legacy repo

* Make Helm webapp chart images configurable

Adds configurability for init and token syncer container images through
new values in the Helm chart configuration

* chore(helm): refactor utility image config

* chore(helm): bump chart version to 4.0.3

---------

Co-authored-by: LeoKaynan <leokaynan@hotmail.com>
2025-09-30 16:02:08 +01:00
nicktrn 12cceaa779 feat(helm): support topology spread constraints for webapp (#2560)
* feat(helm): support topology spread constraints

* chore(helm): update topology env var

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore(helm): limit spread constraints to webapp for now

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-30 15:31:04 +01:00
Saadi Myftija ddebe4dce0 feat(webapp): rate limit magic-link login attempts (#2568)
* feat(webapp): rate limit magic-link login attempts

Adds a simple rate limiter to the login with magic link flow. Similar implementation to the MFA rate limits.

* Fix error message

* Add an env var feature flags for login rate limiting

* Use BoolEnv instead of `0`/`1`

* Parse xff properly
2025-09-29 17:28:36 +02:00
Saadi Myftija 09d51c6d24 fix(webapp): add recommended security headers (#2569)
Sets `Referrer-Policy`, `X-Content-Type-Options` and `Permissions-Policy` headers.
Relevant against certain types of attacks.
2025-09-29 16:55:22 +02:00
Saadi Myftija 3ceea774a8 fix(run-engine): waitpoint update misleading error logs (#2566) 2025-09-26 21:16:12 +02:00
Eric Allam 05b6a26c4f fix(run-engine): pass through engine fair dequeue selection strategy options instead of using defaults (#2565) 2025-09-26 17:12:36 +01:00
Eric Allam 558fb11b89 feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status (#2564)
* feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing
feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status

* Handle FINISHED snapshot in the repair
2025-09-26 15:16:10 +01:00
nicktrn 9aedda23a4 fix(run-engine): carryover batchId after PENDING_EXECUTING stalls (#2563) 2025-09-26 14:14:28 +01:00
Eric Allam 743b8dbe0c chore(run-engine): add additional logging around dequeueing and worker queues (#2562) 2025-09-26 11:50:21 +01:00
Eric Allam eb0263e942 feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing (#2559) 2025-09-25 19:33:10 +01:00
Saadi Myftija 7bf579fa50 fix: use higher entropy invite tokens (#2558)
* fix: use higher entropy invite tokens

We currently use CUIDs for invite tokens, which are generated using
a pattern and are not cryptographically secure. This PR switches to
a higher entropy string generated with `nanoid`.

* Dedupe the invite emails in the application
2025-09-25 19:42:59 +02:00
Saadi Myftija 69d52db856 fix(webapp): org scoping issues in plan selection, alerts, pats and usage (#2549)
* fix: org scoping in the select plan flow

Adds proper org scoping in the loader and action in the plans page.

* Fix billing alerts scope

* Fix org usage page scope

* Fix token revoking flow scope check

* Throw error for failed PAT revokes instead of silent failure
2025-09-25 19:17:51 +02:00
Saadi Myftija a3cea1302e fix(webapp): org invite scoping (#2554)
* fix: org invite scoping

Fixes some scoping issues with team invites.

* Fix invite flow changes
2025-09-25 19:17:19 +02:00
Eric Allam a3bdd3c64b chore(run-engine): improve concurrency sweeper logging to get better visibility (#2557) 2025-09-25 16:43:43 +01:00
Saadi Myftija 6d6e98aa11 fix(webapp): project scoping for runs (#2553)
* fix: project scoping for runs

* Apply some 🐰 suggestions
2025-09-25 17:43:13 +02:00
Eric Allam e22c321dd1 fix(engine) truncate errors before storing them on a run and waitpoint output (#2552) 2025-09-25 13:39:51 +01:00
Eric Allam 59df4af1eb chore(engine): add additional logging when we fail to get snapshots since (#2551) 2025-09-25 12:59:02 +02:00
Eric Allam 8863ff05c9 fix(engine): limit the number of snapshots returned when getting latest snapshots since (#2550) 2025-09-25 11:53:35 +01:00
Matt Aitken d10281e655 Additional files docs with legacyDevProcessCwdBehaviour (#2543) 2025-09-24 14:32:02 +01:00
nicktrn 6798d57e72 chore(helm): bump image versions to 4.0.4 (#2537) 2025-09-24 14:31:30 +01:00
Saadi Myftija 480c0d34d3 fix(webapp): toast message issue after gh app installation (#2546)
* fix(webapp): toast message issue after gh app installation

Fixes an issue with displaying toasts messages in the project settings
page. The github callback cookie was interfering with the flash cookie used
for toast messages.

* Do not set a tracking branch in the staging env by default
2025-09-24 15:20:03 +02:00
Saadi Myftija 700a6ea598 feat: enable canceling deployments (#2545)
* Add canceledAt to the deployment db schema

* Expose an api endpoint to cancel deployments

* Show the canceled status description in the dashboard

* Enable canceling deployments from the dashboard

* Show cancelation reason in the deployment details

* Make verifyProjectMembership a function for consistency

* Apply some good 🐰 suggestions
2025-09-24 11:14:29 +02:00
Saadi Myftija cc94d121f2 feat: installing status for deployments (#2544)
* Add installing status to the deployment db schema

* Replace the deployments /start endpoint with /progress

* Show the installing status in the dashboard

* Add installing status to the api schema and cli

* Add changeset
2025-09-24 10:27:43 +02:00
Saadi Myftija 412e80fdde fix(webapp): hide outdated connected repos from deleted installations (#2538) 2025-09-23 13:24:58 +01:00
Saadi Myftija 49728b5a5f feat(api): defer remote build creation for pending deployments (#2536)
Depot builds have short-lived tokens and their TTL is not exposed in the SDK. As queued deployments can stay in the queue for an arbitrary amount of time, deferring the remote build creation helps avoid expired Depot token issues.
2025-09-22 13:01:56 +02:00
Saadi Myftija d45696c000 fix(api): 204 response issue in deployment start endpoint (#2534) 2025-09-19 19:05:18 +02:00
Eric Allam 8313800746 fix(webapp): don't override spans from ancestors unless the span is partial (#2532) 2025-09-19 14:58:53 +01:00
James Ritchie 28f8cee3a4 Fix for errors returned from searching time specific queries (#2525)
* Fix for errors returned from searching time specific queries

* Adds prompt patterns
2025-09-19 13:49:43 +01:00
Eric Allam 87b3603b23 feat(webapp): completing spans server-side no longer write-after-read, improving efficiency and perf (#2530)
* Cancel run events which then propogate cancellation status to span ancestors

* WIP

* convert closing cached run spans to new system

* converted expired complete span event to new method

* move v3 over to new methods

* Convert getDetailedTraceSummary to use the new ancestor override stuff

* remove debug logs

* Don't return UNSPECIFIED task events in getRunEvents

* fix the call site for cancelling run event in v3

* Add changeset

* remove methods
2025-09-19 13:39:48 +01:00
James Ritchie 365adc24a6 Chore(webapp): adds more copy buttons (#2529)
* Pass up asChild

* Include padding when asChild specified

* Adds copy buttons to useful data in Details tab

* Improve view batch tooltip message
2025-09-19 13:02:10 +01:00
Saadi Myftija 7d17730b52 feat(api): handle build server deployment init gracefully for older cli versionsi (#2526)
This PR adapts the deployment initialization endpoint to handle build server deployments with older CLI versions gracefully.

When we introduced automatic deployments via the build server, we slightly changed the deployment flow
mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status
and updated to `BUILDING` once the build server dequeues the build job.
Newer versions of the `deploy` command in the CLI will automatically attach to the existing deployment
and continue with the build process. For older versions, we can't change the command's client-side behavior,
so we need to handle this case here in the initialization endpoint. As we control the env variables which
the git meta is extracted from in the build server, we can use those to pass the existing deployment ID
to this endpoint. This doesn't affect the git meta on the deployment as it is set prior to this step using the
/start endpoint. It's a rather hacky solution, but it will do for now as it enables us to avoid degrading the
build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
once we stop supporting 3.x CLI versions.
2025-09-19 10:40:59 +02:00
Saadi Myftija e4982bfd6d feat(webapp): deployments page live reloading (#2524)
* Fix `current` badge inconsistency in the deployment details page

* Add custom hook for auto revalidation based on an interval and/or focus change

* Use the autoRevalidate hook for live reloading of the deployments page

* Extract autoReloadPollIntervalMs to an env var

* Replace the sse-based autoreload in bulk actions and queues page with the simpler autoRevalidate hook
2025-09-19 10:40:07 +02:00
1051 changed files with 178547 additions and 31356 deletions
+16
View File
@@ -0,0 +1,16 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---
Add `maxDelay` option to debounce feature. This allows setting a maximum time limit for how long a debounced run can be delayed, ensuring execution happens within a specified window even with continuous triggers.
```typescript
await myTask.trigger(payload, {
debounce: {
key: "my-key",
delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous triggers
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/react-hooks": patch
---
Fix `onComplete` callback firing prematurely when the realtime stream disconnects before the run finishes.
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Aligned the SDK's `getRunIdForOptions` logic with the Core package to handle semantic targets (`root`, `parent`) in root tasks.
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/core": patch
---
fix: vendor superjson to fix ESM/CJS compatibility
Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it.
+200
View File
@@ -0,0 +1,200 @@
---
name: trigger-dev-tasks
description: Use this skill when writing, designing, or optimizing Trigger.dev background tasks and workflows. This includes creating reliable async tasks, implementing AI workflows, setting up scheduled jobs, structuring complex task hierarchies with subtasks, configuring build extensions for tools like ffmpeg or Puppeteer/Playwright, and handling task schemas with Zod validation.
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Trigger.dev Task Expert
You are an expert Trigger.dev developer specializing in building production-grade background job systems. Tasks deployed to Trigger.dev run in Node.js 21+ and use the `@trigger.dev/sdk` package.
## Critical Rules
1. **Always use `@trigger.dev/sdk`** - Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern
2. **Never use `node-fetch`** - Use the built-in `fetch` function
3. **Export all tasks** - Every task must be exported, including subtasks
4. **Never wrap wait/trigger calls in Promise.all** - `triggerAndWait`, `batchTriggerAndWait`, and `wait.*` calls cannot be wrapped in `Promise.all` or `Promise.allSettled`
## Basic Task Pattern
```ts
import { task } from "@trigger.dev/sdk";
export const processData = task({
id: "process-data",
retry: {
maxAttempts: 10,
factor: 1.8,
minTimeoutInMs: 500,
maxTimeoutInMs: 30_000,
},
run: async (payload: { userId: string; data: any[] }) => {
console.log(`Processing ${payload.data.length} items`);
return { processed: payload.data.length };
},
});
```
## Schema Task (with validation)
```ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const validatedTask = schemaTask({
id: "validated-task",
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
run: async (payload) => {
// Payload is automatically validated and typed
return { message: `Hello ${payload.name}` };
},
});
```
## Triggering Tasks
### From Backend Code (type-only import to prevent dependency leakage)
```ts
import { tasks } from "@trigger.dev/sdk";
import type { processData } from "./trigger/tasks";
const handle = await tasks.trigger<typeof processData>("process-data", {
userId: "123",
data: [{ id: 1 }],
});
```
### From Inside Tasks
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload) => {
// Trigger and wait - returns Result object, NOT direct output
const result = await childTask.triggerAndWait({ data: "value" });
if (result.ok) {
console.log("Output:", result.output);
} else {
console.error("Failed:", result.error);
}
// Or unwrap directly (throws on error)
const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
},
});
```
## Idempotency (Critical for Retries)
Always use idempotency keys when triggering tasks from inside other tasks:
```ts
import { idempotencyKeys } from "@trigger.dev/sdk";
export const paymentTask = task({
id: "process-payment",
run: async (payload: { orderId: string }) => {
// Scoped to current run - survives retries
const key = await idempotencyKeys.create(`payment-${payload.orderId}`);
await chargeCustomer.trigger(payload, {
idempotencyKey: key,
idempotencyKeyTTL: "24h",
});
},
});
```
## Trigger Options
```ts
await myTask.trigger(payload, {
delay: "1h", // Delay execution
ttl: "10m", // Cancel if not started within TTL
idempotencyKey: key,
queue: "my-queue",
machine: "large-1x", // micro, small-1x, small-2x, medium-1x, medium-2x, large-1x, large-2x
maxAttempts: 3,
tags: ["user_123"], // Max 10 tags
debounce: { // Consolidate rapid triggers
key: "unique-key",
delay: "5s",
mode: "trailing", // "leading" (default) or "trailing"
},
});
```
## Debouncing
Consolidate multiple triggers into a single execution:
```ts
// Rapid triggers with same key = single execution
await myTask.trigger({ userId: "123" }, {
debounce: {
key: "user-123-update",
delay: "5s",
},
});
// Trailing mode: use payload from LAST trigger
await myTask.trigger({ data: "latest" }, {
debounce: {
key: "my-key",
delay: "10s",
mode: "trailing",
},
});
```
Use cases: user activity updates, webhook deduplication, search indexing, notification batching.
## Batch Triggering
Up to 1,000 items per batch, 3MB per payload:
```ts
const results = await myTask.batchTriggerAndWait([
{ payload: { userId: "1" } },
{ payload: { userId: "2" } },
]);
for (const result of results) {
if (result.ok) console.log(result.output);
}
```
## Machine Presets
| Preset | vCPU | Memory |
|-------------|------|--------|
| micro | 0.25 | 0.25GB |
| small-1x | 0.5 | 0.5GB |
| small-2x | 1 | 1GB |
| medium-1x | 1 | 2GB |
| medium-2x | 2 | 4GB |
| large-1x | 4 | 8GB |
| large-2x | 8 | 16GB |
## Design Principles
1. **Break complex workflows into subtasks** that can be independently retried and made idempotent
2. **Don't over-complicate** - Sometimes `Promise.allSettled` inside a single task is better than many subtasks (each task has dedicated process and is charged by millisecond)
3. **Always configure retries** - Set appropriate `maxAttempts` based on the operation
4. **Use idempotency keys** - Especially for payment/critical operations
5. **Group related subtasks** - Keep subtasks only used by one parent in the same file, don't export them
6. **Use logger** - Log at key execution points with `logger.info()`, `logger.error()`, etc.
## Reference Documentation
For detailed documentation on specific topics, read these files:
- `basic-tasks.md` - Task basics, triggering, waits
- `advanced-tasks.md` - Tags, queues, concurrency, metadata, error handling
- `scheduled-tasks.md` - Cron schedules, declarative and imperative
- `realtime.md` - Real-time subscriptions, streams, React hooks
- `config.md` - trigger.config.ts, build extensions (Prisma, Playwright, FFmpeg, etc.)
@@ -0,0 +1,485 @@
# Trigger.dev Advanced Tasks (v4)
**Advanced patterns and features for writing tasks**
## Tags & Organization
```ts
import { task, tags } from "@trigger.dev/sdk";
export const processUser = task({
id: "process-user",
run: async (payload: { userId: string; orgId: string }, { ctx }) => {
// Add tags during execution
await tags.add(`user_${payload.userId}`);
await tags.add(`org_${payload.orgId}`);
return { processed: true };
},
});
// Trigger with tags
await processUser.trigger(
{ userId: "123", orgId: "abc" },
{ tags: ["priority", "user_123", "org_abc"] } // Max 10 tags per run
);
// Subscribe to tagged runs
for await (const run of runs.subscribeToRunsWithTag("user_123")) {
console.log(`User task ${run.id}: ${run.status}`);
}
```
**Tag Best Practices:**
- Use prefixes: `user_123`, `org_abc`, `video:456`
- Max 10 tags per run, 1-64 characters each
- Tags don't propagate to child tasks automatically
## Batch Triggering v2
Enhanced batch triggering with larger payloads and streaming ingestion.
### Limits
- **Maximum batch size**: 1,000 items (increased from 500)
- **Payload per item**: 3MB each (increased from 1MB combined)
- Payloads > 512KB automatically offload to object storage
### Rate Limiting (per environment)
| Tier | Bucket Size | Refill Rate |
|------|-------------|-------------|
| Free | 1,200 runs | 100 runs/10 sec |
| Hobby | 5,000 runs | 500 runs/5 sec |
| Pro | 5,000 runs | 500 runs/5 sec |
### Concurrent Batch Processing
| Tier | Concurrent Batches |
|------|-------------------|
| Free | 1 |
| Hobby | 10 |
| Pro | 10 |
### Usage
```ts
import { myTask } from "./trigger/myTask";
// Basic batch trigger (up to 1,000 items)
const runs = await myTask.batchTrigger([
{ payload: { userId: "user-1" } },
{ payload: { userId: "user-2" } },
{ payload: { userId: "user-3" } },
]);
// Batch trigger with wait
const results = await myTask.batchTriggerAndWait([
{ payload: { userId: "user-1" } },
{ payload: { userId: "user-2" } },
]);
for (const result of results) {
if (result.ok) {
console.log("Result:", result.output);
}
}
// With per-item options
const batchHandle = await myTask.batchTrigger([
{
payload: { userId: "123" },
options: {
idempotencyKey: "user-123-batch",
tags: ["priority"],
},
},
{
payload: { userId: "456" },
options: {
idempotencyKey: "user-456-batch",
},
},
]);
```
## Debouncing
Consolidate multiple triggers into a single execution by debouncing task runs with a unique key and delay window.
### Use Cases
- **User activity updates**: Batch rapid user actions into a single run
- **Webhook deduplication**: Handle webhook bursts without redundant processing
- **Search indexing**: Combine document updates instead of processing individually
- **Notification batching**: Group notifications to prevent user spam
### Basic Usage
```ts
await myTask.trigger(
{ userId: "123" },
{
debounce: {
key: "user-123-update", // Unique identifier for debounce group
delay: "5s", // Wait duration ("5s", "1m", or milliseconds)
},
}
);
```
### Execution Modes
**Leading Mode** (default): Uses payload/options from the first trigger; subsequent triggers only reschedule execution time.
```ts
// First trigger sets the payload
await myTask.trigger({ action: "first" }, {
debounce: { key: "my-key", delay: "10s" }
});
// Second trigger only reschedules - payload remains "first"
await myTask.trigger({ action: "second" }, {
debounce: { key: "my-key", delay: "10s" }
});
// Task executes with { action: "first" }
```
**Trailing Mode**: Uses payload/options from the most recent trigger.
```ts
await myTask.trigger(
{ data: "latest-value" },
{
debounce: {
key: "trailing-example",
delay: "10s",
mode: "trailing",
},
}
);
```
In trailing mode, these options update with each trigger:
- `payload` — task input data
- `metadata` — run metadata
- `tags` — run tags (replaces existing)
- `maxAttempts` — retry attempts
- `maxDuration` — maximum compute time
- `machine` — machine preset
### Important Notes
- Idempotency keys take precedence over debounce settings
- Compatible with `triggerAndWait()` — parent runs block correctly on debounced execution
- Debounce key is scoped to the task
## Concurrency & Queues
```ts
import { task, queue } from "@trigger.dev/sdk";
// Shared queue for related tasks
const emailQueue = queue({
name: "email-processing",
concurrencyLimit: 5, // Max 5 emails processing simultaneously
});
// Task-level concurrency
export const oneAtATime = task({
id: "sequential-task",
queue: { concurrencyLimit: 1 }, // Process one at a time
run: async (payload) => {
// Critical section - only one instance runs
},
});
// Per-user concurrency
export const processUserData = task({
id: "process-user-data",
run: async (payload: { userId: string }) => {
// Override queue with user-specific concurrency
await childTask.trigger(payload, {
queue: {
name: `user-${payload.userId}`,
concurrencyLimit: 2,
},
});
},
});
export const emailTask = task({
id: "send-email",
queue: emailQueue, // Use shared queue
run: async (payload: { to: string }) => {
// Send email logic
},
});
```
## Error Handling & Retries
```ts
import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk";
export const resilientTask = task({
id: "resilient-task",
retry: {
maxAttempts: 10,
factor: 1.8, // Exponential backoff multiplier
minTimeoutInMs: 500,
maxTimeoutInMs: 30_000,
randomize: false,
},
catchError: async ({ error, ctx }) => {
// Custom error handling
if (error.code === "FATAL_ERROR") {
throw new AbortTaskRunError("Cannot retry this error");
}
// Log error details
console.error(`Task ${ctx.task.id} failed:`, error);
// Allow retry by returning nothing
return { retryAt: new Date(Date.now() + 60000) }; // Retry in 1 minute
},
run: async (payload) => {
// Retry specific operations
const result = await retry.onThrow(
async () => {
return await unstableApiCall(payload);
},
{ maxAttempts: 3 }
);
// Conditional HTTP retries
const response = await retry.fetch("https://api.example.com", {
retry: {
maxAttempts: 5,
condition: (response, error) => {
return response?.status === 429 || response?.status >= 500;
},
},
});
return result;
},
});
```
## Machines & Performance
```ts
export const heavyTask = task({
id: "heavy-computation",
machine: { preset: "large-2x" }, // 8 vCPU, 16 GB RAM
maxDuration: 1800, // 30 minutes timeout
run: async (payload, { ctx }) => {
// Resource-intensive computation
if (ctx.machine.preset === "large-2x") {
// Use all available cores
return await parallelProcessing(payload);
}
return await standardProcessing(payload);
},
});
// Override machine when triggering
await heavyTask.trigger(payload, {
machine: { preset: "medium-1x" }, // Override for this run
});
```
**Machine Presets:**
- `micro`: 0.25 vCPU, 0.25 GB RAM
- `small-1x`: 0.5 vCPU, 0.5 GB RAM (default)
- `small-2x`: 1 vCPU, 1 GB RAM
- `medium-1x`: 1 vCPU, 2 GB RAM
- `medium-2x`: 2 vCPU, 4 GB RAM
- `large-1x`: 4 vCPU, 8 GB RAM
- `large-2x`: 8 vCPU, 16 GB RAM
## Idempotency
```ts
import { task, idempotencyKeys } from "@trigger.dev/sdk";
export const paymentTask = task({
id: "process-payment",
retry: {
maxAttempts: 3,
},
run: async (payload: { orderId: string; amount: number }) => {
// Automatically scoped to this task run, so if the task is retried, the idempotency key will be the same
const idempotencyKey = await idempotencyKeys.create(`payment-${payload.orderId}`);
// Ensure payment is processed only once
await chargeCustomer.trigger(payload, {
idempotencyKey,
idempotencyKeyTTL: "24h", // Key expires in 24 hours
});
},
});
// Payload-based idempotency
import { createHash } from "node:crypto";
function createPayloadHash(payload: any): string {
const hash = createHash("sha256");
hash.update(JSON.stringify(payload));
return hash.digest("hex");
}
export const deduplicatedTask = task({
id: "deduplicated-task",
run: async (payload) => {
const payloadHash = createPayloadHash(payload);
const idempotencyKey = await idempotencyKeys.create(payloadHash);
await processData.trigger(payload, { idempotencyKey });
},
});
```
## Metadata & Progress Tracking
```ts
import { task, metadata } from "@trigger.dev/sdk";
export const batchProcessor = task({
id: "batch-processor",
run: async (payload: { items: any[] }, { ctx }) => {
const totalItems = payload.items.length;
// Initialize progress metadata
metadata
.set("progress", 0)
.set("totalItems", totalItems)
.set("processedItems", 0)
.set("status", "starting");
const results = [];
for (let i = 0; i < payload.items.length; i++) {
const item = payload.items[i];
// Process item
const result = await processItem(item);
results.push(result);
// Update progress
const progress = ((i + 1) / totalItems) * 100;
metadata
.set("progress", progress)
.increment("processedItems", 1)
.append("logs", `Processed item ${i + 1}/${totalItems}`)
.set("currentItem", item.id);
}
// Final status
metadata.set("status", "completed");
return { results, totalProcessed: results.length };
},
});
// Update parent metadata from child task
export const childTask = task({
id: "child-task",
run: async (payload, { ctx }) => {
// Update parent task metadata
metadata.parent.set("childStatus", "processing");
metadata.root.increment("childrenCompleted", 1);
return { processed: true };
},
});
```
## Logging & Tracing
```ts
import { task, logger } from "@trigger.dev/sdk";
export const tracedTask = task({
id: "traced-task",
run: async (payload, { ctx }) => {
logger.info("Task started", { userId: payload.userId });
// Custom trace with attributes
const user = await logger.trace(
"fetch-user",
async (span) => {
span.setAttribute("user.id", payload.userId);
span.setAttribute("operation", "database-fetch");
const userData = await database.findUser(payload.userId);
span.setAttribute("user.found", !!userData);
return userData;
},
{ userId: payload.userId }
);
logger.debug("User fetched", { user: user.id });
try {
const result = await processUser(user);
logger.info("Processing completed", { result });
return result;
} catch (error) {
logger.error("Processing failed", {
error: error.message,
userId: payload.userId,
});
throw error;
}
},
});
```
## Hidden Tasks
```ts
// Hidden task - not exported, only used internally
const internalProcessor = task({
id: "internal-processor",
run: async (payload: { data: string }) => {
return { processed: payload.data.toUpperCase() };
},
});
// Public task that uses hidden task
export const publicWorkflow = task({
id: "public-workflow",
run: async (payload: { input: string }) => {
// Use hidden task internally
const result = await internalProcessor.triggerAndWait({
data: payload.input,
});
if (result.ok) {
return { output: result.output.processed };
}
throw new Error("Internal processing failed");
},
});
```
## Best Practices
- **Concurrency**: Use queues to prevent overwhelming external services
- **Retries**: Configure exponential backoff for transient failures
- **Idempotency**: Always use for payment/critical operations
- **Metadata**: Track progress for long-running tasks
- **Machines**: Match machine size to computational requirements
- **Tags**: Use consistent naming patterns for filtering
- **Debouncing**: Use for user activity, webhooks, and notification batching
- **Batch triggering**: Use for bulk operations up to 1,000 items
- **Error Handling**: Distinguish between retryable and fatal errors
Design tasks to be stateless, idempotent, and resilient to failures. Use metadata for state tracking and queues for resource management.
@@ -0,0 +1,199 @@
# Trigger.dev Basic Tasks (v4)
**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`**
## Basic Task
```ts
import { task } from "@trigger.dev/sdk";
export const processData = task({
id: "process-data",
retry: {
maxAttempts: 10,
factor: 1.8,
minTimeoutInMs: 500,
maxTimeoutInMs: 30_000,
randomize: false,
},
run: async (payload: { userId: string; data: any[] }) => {
// Task logic - runs for long time, no timeouts
console.log(`Processing ${payload.data.length} items for user ${payload.userId}`);
return { processed: payload.data.length };
},
});
```
## Schema Task (with validation)
```ts
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const validatedTask = schemaTask({
id: "validated-task",
schema: z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
}),
run: async (payload) => {
// Payload is automatically validated and typed
return { message: `Hello ${payload.name}, age ${payload.age}` };
},
});
```
## Triggering Tasks
### From Backend Code
```ts
import { tasks } from "@trigger.dev/sdk";
import type { processData } from "./trigger/tasks";
// Single trigger
const handle = await tasks.trigger<typeof processData>("process-data", {
userId: "123",
data: [{ id: 1 }, { id: 2 }],
});
// Batch trigger (up to 1,000 items, 3MB per payload)
const batchHandle = await tasks.batchTrigger<typeof processData>("process-data", [
{ payload: { userId: "123", data: [{ id: 1 }] } },
{ payload: { userId: "456", data: [{ id: 2 }] } },
]);
```
### Debounced Triggering
Consolidate multiple triggers into a single execution:
```ts
// Multiple rapid triggers with same key = single execution
await myTask.trigger(
{ userId: "123" },
{
debounce: {
key: "user-123-update", // Unique key for debounce group
delay: "5s", // Wait before executing
},
}
);
// Trailing mode: use payload from LAST trigger
await myTask.trigger(
{ data: "latest-value" },
{
debounce: {
key: "trailing-example",
delay: "10s",
mode: "trailing", // Default is "leading" (first payload)
},
}
);
```
**Debounce modes:**
- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule
- `trailing`: Uses payload from most recent trigger
### From Inside Tasks (with Result handling)
```ts
export const parentTask = task({
id: "parent-task",
run: async (payload) => {
// Trigger and continue
const handle = await childTask.trigger({ data: "value" });
// Trigger and wait - returns Result object, NOT task output
const result = await childTask.triggerAndWait({ data: "value" });
if (result.ok) {
console.log("Task output:", result.output); // Actual task return value
} else {
console.error("Task failed:", result.error);
}
// Quick unwrap (throws on error)
const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
// Batch trigger and wait
const results = await childTask.batchTriggerAndWait([
{ payload: { data: "item1" } },
{ payload: { data: "item2" } },
]);
for (const run of results) {
if (run.ok) {
console.log("Success:", run.output);
} else {
console.log("Failed:", run.error);
}
}
},
});
export const childTask = task({
id: "child-task",
run: async (payload: { data: string }) => {
return { processed: payload.data };
},
});
```
> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks.
## Waits
```ts
import { task, wait } from "@trigger.dev/sdk";
export const taskWithWaits = task({
id: "task-with-waits",
run: async (payload) => {
console.log("Starting task");
// Wait for specific duration
await wait.for({ seconds: 30 });
await wait.for({ minutes: 5 });
await wait.for({ hours: 1 });
await wait.for({ days: 1 });
// Wait until specific date
await wait.until({ date: new Date("2024-12-25") });
// Wait for token (from external system)
await wait.forToken({
token: "user-approval-token",
timeoutInSeconds: 3600, // 1 hour timeout
});
console.log("All waits completed");
return { status: "completed" };
},
});
```
> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks.
## Key Points
- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output
- **Type safety**: Use `import type` for task references when triggering from backend
- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage
- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings
## NEVER Use (v2 deprecated)
```ts
// BREAKS APPLICATION
client.defineJob({
id: "job-id",
run: async (payload, io) => {
/* ... */
},
});
```
Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output`
+346
View File
@@ -0,0 +1,346 @@
# Trigger.dev Configuration
**Complete guide to configuring `trigger.config.ts` with build extensions**
## Basic Configuration
```ts
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "<project-ref>", // Required: Your project reference
dirs: ["./trigger"], // Task directories
runtime: "node", // "node", "node-22", or "bun"
logLevel: "info", // "debug", "info", "warn", "error"
// Default retry settings
retries: {
enabledInDev: false,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
randomize: true,
},
},
// Build configuration
build: {
autoDetectExternal: true,
keepNames: true,
minify: false,
extensions: [], // Build extensions go here
},
// Global lifecycle hooks
onStartAttempt: async ({ payload, ctx }) => {
console.log("Global task start");
},
onSuccess: async ({ payload, output, ctx }) => {
console.log("Global task success");
},
onFailure: async ({ payload, error, ctx }) => {
console.log("Global task failure");
},
});
```
## Build Extensions
### Database & ORM
#### Prisma
```ts
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
extensions: [
prismaExtension({
schema: "prisma/schema.prisma",
version: "5.19.0", // Optional: specify version
migrate: true, // Run migrations during build
directUrlEnvVarName: "DIRECT_DATABASE_URL",
typedSql: true, // Enable TypedSQL support
}),
];
```
#### TypeScript Decorators (for TypeORM)
```ts
import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript";
extensions: [
emitDecoratorMetadata(), // Enables decorator metadata
];
```
### Scripting Languages
#### Python
```ts
import { pythonExtension } from "@trigger.dev/build/extensions/python";
extensions: [
pythonExtension({
scripts: ["./python/**/*.py"], // Copy Python files
requirementsFile: "./requirements.txt", // Install packages
devPythonBinaryPath: ".venv/bin/python", // Dev mode binary
}),
];
// Usage in tasks
const result = await python.runInline(`print("Hello, world!")`);
const output = await python.runScript("./python/script.py", ["arg1"]);
```
### Browser Automation
#### Playwright
```ts
import { playwright } from "@trigger.dev/build/extensions/playwright";
extensions: [
playwright({
browsers: ["chromium", "firefox", "webkit"], // Default: ["chromium"]
headless: true, // Default: true
}),
];
```
#### Puppeteer
```ts
import { puppeteer } from "@trigger.dev/build/extensions/puppeteer";
extensions: [puppeteer()];
// Environment variable needed:
// PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable"
```
#### Lightpanda
```ts
import { lightpanda } from "@trigger.dev/build/extensions/lightpanda";
extensions: [
lightpanda({
version: "latest", // or "nightly"
disableTelemetry: false,
}),
];
```
### Media Processing
#### FFmpeg
```ts
import { ffmpeg } from "@trigger.dev/build/extensions/core";
extensions: [
ffmpeg({ version: "7" }), // Static build, or omit for Debian version
];
// Automatically sets FFMPEG_PATH and FFPROBE_PATH
// Add fluent-ffmpeg to external packages if using
```
#### Audio Waveform
```ts
import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform";
extensions: [
audioWaveform(), // Installs Audio Waveform 1.1.0
];
```
### System & Package Management
#### System Packages (apt-get)
```ts
import { aptGet } from "@trigger.dev/build/extensions/core";
extensions: [
aptGet({
packages: ["ffmpeg", "imagemagick", "curl=7.68.0-1"], // Can specify versions
}),
];
```
#### Additional NPM Packages
Only use this for installing CLI tools, NOT packages you import in your code.
```ts
import { additionalPackages } from "@trigger.dev/build/extensions/core";
extensions: [
additionalPackages({
packages: ["wrangler"], // CLI tools and specific versions
}),
];
```
#### Additional Files
```ts
import { additionalFiles } from "@trigger.dev/build/extensions/core";
extensions: [
additionalFiles({
files: ["wrangler.toml", "./assets/**", "./fonts/**"], // Glob patterns supported
}),
];
```
### Environment & Build Tools
#### Environment Variable Sync
```ts
import { syncEnvVars } from "@trigger.dev/build/extensions/core";
extensions: [
syncEnvVars(async (ctx) => {
// ctx contains: environment, projectRef, env
return [
{ name: "SECRET_KEY", value: await getSecret(ctx.environment) },
{ name: "API_URL", value: ctx.environment === "prod" ? "api.prod.com" : "api.dev.com" },
];
}),
];
```
#### ESBuild Plugins
```ts
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin";
extensions: [
esbuildPlugin(
sentryEsbuildPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
{ placement: "last", target: "deploy" } // Optional config
),
];
```
## Custom Build Extensions
```ts
import { defineConfig } from "@trigger.dev/sdk";
const customExtension = {
name: "my-custom-extension",
externalsForTarget: (target) => {
return ["some-native-module"]; // Add external dependencies
},
onBuildStart: async (context) => {
console.log(`Build starting for ${context.target}`);
// Register esbuild plugins, modify build context
},
onBuildComplete: async (context, manifest) => {
console.log("Build complete, adding layers");
// Add build layers, modify deployment
context.addLayer({
id: "my-layer",
files: [{ source: "./custom-file", destination: "/app/custom" }],
commands: ["chmod +x /app/custom"],
});
},
};
export default defineConfig({
project: "my-project",
build: {
extensions: [customExtension],
},
});
```
## Advanced Configuration
### Telemetry
```ts
import { PrismaInstrumentation } from "@prisma/instrumentation";
import { OpenAIInstrumentation } from "@langfuse/openai";
export default defineConfig({
// ... other config
telemetry: {
instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()],
exporters: [customExporter], // Optional custom exporters
},
});
```
### Machine & Performance
```ts
export default defineConfig({
// ... other config
defaultMachine: "large-1x", // Default machine for all tasks
maxDuration: 300, // Default max duration (seconds)
enableConsoleLogging: true, // Console logging in development
});
```
## Common Extension Combinations
### Full-Stack Web App
```ts
extensions: [
prismaExtension({ schema: "prisma/schema.prisma", migrate: true }),
additionalFiles({ files: ["./public/**", "./assets/**"] }),
syncEnvVars(async (ctx) => [...envVars]),
];
```
### AI/ML Processing
```ts
extensions: [
pythonExtension({
scripts: ["./ai/**/*.py"],
requirementsFile: "./requirements.txt",
}),
ffmpeg({ version: "7" }),
additionalPackages({ packages: ["wrangler"] }),
];
```
### Web Scraping
```ts
extensions: [
playwright({ browsers: ["chromium"] }),
puppeteer(),
additionalFiles({ files: ["./selectors.json", "./proxies.txt"] }),
];
```
## Best Practices
- **Use specific versions**: Pin extension versions for reproducible builds
- **External packages**: Add modules with native addons to the `build.external` array
- **Environment sync**: Use `syncEnvVars` for dynamic secrets
- **File paths**: Use glob patterns for flexible file inclusion
- **Debug builds**: Use `--log-level debug --dry-run` for troubleshooting
Extensions only affect deployment, not local development. Use `external` array for packages that shouldn't be bundled.
@@ -0,0 +1,244 @@
# Trigger.dev Realtime
**Real-time monitoring and updates for runs**
## Core Concepts
Realtime allows you to:
- Subscribe to run status changes, metadata updates, and streams
- Build real-time dashboards and UI updates
- Monitor task progress from frontend and backend
## Authentication
### Public Access Tokens
```ts
import { auth } from "@trigger.dev/sdk";
// Read-only token for specific runs
const publicToken = await auth.createPublicToken({
scopes: {
read: {
runs: ["run_123", "run_456"],
tasks: ["my-task-1", "my-task-2"],
},
},
expirationTime: "1h", // Default: 15 minutes
});
```
### Trigger Tokens (Frontend only)
```ts
// Single-use token for triggering tasks
const triggerToken = await auth.createTriggerPublicToken("my-task", {
expirationTime: "30m",
});
```
## Backend Usage
### Subscribe to Runs
```ts
import { runs, tasks } from "@trigger.dev/sdk";
// Trigger and subscribe
const handle = await tasks.trigger("my-task", { data: "value" });
// Subscribe to specific run
for await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {
console.log(`Status: ${run.status}, Progress: ${run.metadata?.progress}`);
if (run.status === "COMPLETED") break;
}
// Subscribe to runs with tag
for await (const run of runs.subscribeToRunsWithTag("user-123")) {
console.log(`Tagged run ${run.id}: ${run.status}`);
}
// Subscribe to batch
for await (const run of runs.subscribeToBatch(batchId)) {
console.log(`Batch run ${run.id}: ${run.status}`);
}
```
### Realtime Streams v2
```ts
import { streams, InferStreamType } from "@trigger.dev/sdk";
// 1. Define streams (shared location)
export const aiStream = streams.define<string>({
id: "ai-output",
});
export type AIStreamPart = InferStreamType<typeof aiStream>;
// 2. Pipe from task
export const streamingTask = task({
id: "streaming-task",
run: async (payload) => {
const completion = await openai.chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: payload.prompt }],
stream: true,
});
const { waitUntilComplete } = aiStream.pipe(completion);
await waitUntilComplete();
},
});
// 3. Read from backend
const stream = await aiStream.read(runId, {
timeoutInSeconds: 300,
startIndex: 0, // Resume from specific chunk
});
for await (const chunk of stream) {
console.log("Chunk:", chunk); // Fully typed
}
```
## React Frontend Usage
### Installation
```bash
npm add @trigger.dev/react-hooks
```
### Triggering Tasks
```tsx
"use client";
import { useTaskTrigger, useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
import type { myTask } from "../trigger/tasks";
function TriggerComponent({ accessToken }: { accessToken: string }) {
// Basic trigger
const { submit, handle, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
accessToken,
});
// Trigger with realtime updates
const {
submit: realtimeSubmit,
run,
isLoading: isRealtimeLoading,
} = useRealtimeTaskTrigger<typeof myTask>("my-task", { accessToken });
return (
<div>
<button onClick={() => submit({ data: "value" })} disabled={isLoading}>
Trigger Task
</button>
<button onClick={() => realtimeSubmit({ data: "realtime" })} disabled={isRealtimeLoading}>
Trigger with Realtime
</button>
{run && <div>Status: {run.status}</div>}
</div>
);
}
```
### Subscribing to Runs
```tsx
"use client";
import { useRealtimeRun, useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
import type { myTask } from "../trigger/tasks";
function SubscribeComponent({ runId, accessToken }: { runId: string; accessToken: string }) {
// Subscribe to specific run
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
accessToken,
onComplete: (run) => {
console.log("Task completed:", run.output);
},
});
// Subscribe to tagged runs
const { runs } = useRealtimeRunsWithTag("user-123", { accessToken });
if (error) return <div>Error: {error.message}</div>;
if (!run) return <div>Loading...</div>;
return (
<div>
<div>Status: {run.status}</div>
<div>Progress: {run.metadata?.progress || 0}%</div>
{run.output && <div>Result: {JSON.stringify(run.output)}</div>}
<h3>Tagged Runs:</h3>
{runs.map((r) => (
<div key={r.id}>
{r.id}: {r.status}
</div>
))}
</div>
);
}
```
### Realtime Streams with React
```tsx
"use client";
import { useRealtimeStream } from "@trigger.dev/react-hooks";
import { aiStream } from "../trigger/streams";
function StreamComponent({ runId, accessToken }: { runId: string; accessToken: string }) {
// Pass defined stream directly for type safety
const { parts, error } = useRealtimeStream(aiStream, runId, {
accessToken,
timeoutInSeconds: 300,
throttleInMs: 50, // Control re-render frequency
});
if (error) return <div>Error: {error.message}</div>;
if (!parts) return <div>Loading...</div>;
const text = parts.join(""); // parts is typed as AIStreamPart[]
return <div>Streamed Text: {text}</div>;
}
```
### Wait Tokens
```tsx
"use client";
import { useWaitToken } from "@trigger.dev/react-hooks";
function WaitTokenComponent({ tokenId, accessToken }: { tokenId: string; accessToken: string }) {
const { complete } = useWaitToken(tokenId, { accessToken });
return <button onClick={() => complete({ approved: true })}>Approve Task</button>;
}
```
## Run Object Properties
Key properties available in run subscriptions:
- `id`: Unique run identifier
- `status`: `QUEUED`, `EXECUTING`, `COMPLETED`, `FAILED`, `CANCELED`, etc.
- `payload`: Task input data (typed)
- `output`: Task result (typed, when completed)
- `metadata`: Real-time updatable data
- `createdAt`, `updatedAt`: Timestamps
- `costInCents`: Execution cost
## Best Practices
- **Use Realtime over SWR**: Recommended for most use cases due to rate limits
- **Scope tokens properly**: Only grant necessary read/trigger permissions
- **Handle errors**: Always check for errors in hooks and subscriptions
- **Type safety**: Use task types for proper payload/output typing
- **Cleanup subscriptions**: Backend subscriptions auto-complete, frontend hooks auto-cleanup
@@ -0,0 +1,113 @@
# Scheduled Tasks (Cron)
Recurring tasks using cron. For one-off future runs, use the **delay** option.
## Define a Scheduled Task
```ts
import { schedules } from "@trigger.dev/sdk";
export const task = schedules.task({
id: "first-scheduled-task",
run: async (payload) => {
payload.timestamp; // Date (scheduled time, UTC)
payload.lastTimestamp; // Date | undefined
payload.timezone; // IANA, e.g. "America/New_York" (default "UTC")
payload.scheduleId; // string
payload.externalId; // string | undefined
payload.upcoming; // Date[]
payload.timestamp.toLocaleString("en-US", { timeZone: payload.timezone });
},
});
```
> Scheduled tasks need at least one schedule attached to run.
## Attach Schedules
**Declarative (sync on dev/deploy):**
```ts
schedules.task({
id: "every-2h",
cron: "0 */2 * * *", // UTC
run: async () => {},
});
schedules.task({
id: "tokyo-5am",
cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo", environments: ["PRODUCTION", "STAGING"] },
run: async () => {},
});
```
**Imperative (SDK or dashboard):**
```ts
await schedules.create({
task: task.id,
cron: "0 0 * * *",
timezone: "America/New_York", // DST-aware
externalId: "user_123",
deduplicationKey: "user_123-daily", // updates if reused
});
```
### Dynamic / Multi-tenant Example
```ts
// /trigger/reminder.ts
export const reminderTask = schedules.task({
id: "todo-reminder",
run: async (p) => {
if (!p.externalId) throw new Error("externalId is required");
const user = await db.getUser(p.externalId);
await sendReminderEmail(user);
},
});
```
```ts
// app/reminders/route.ts
export async function POST(req: Request) {
const data = await req.json();
return Response.json(
await schedules.create({
task: reminderTask.id,
cron: "0 8 * * *",
timezone: data.timezone,
externalId: data.userId,
deduplicationKey: `${data.userId}-reminder`,
})
);
}
```
## Cron Syntax (no seconds)
```
* * * * *
| | | | └ day of week (07 or 1L7L; 0/7=Sun; L=last)
| | | └── month (112)
| | └──── day of month (131 or L)
| └────── hour (023)
└──────── minute (059)
```
## When Schedules Won't Trigger
- **Dev:** only when the dev CLI is running.
- **Staging/Production:** only for tasks in the **latest deployment**.
## SDK Management
```ts
await schedules.retrieve(id);
await schedules.list();
await schedules.update(id, { cron: "0 0 1 * *", externalId: "ext", deduplicationKey: "key" });
await schedules.deactivate(id);
await schedules.activate(id);
await schedules.del(id);
await schedules.timezones(); // list of IANA timezones
```
+11
View File
@@ -0,0 +1,11 @@
# Remove AI code slop
Check the diff against main, and remove all AI generated slop introduced in this branch.
This includes:
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
- Casts to any to get around type issues
- Any other style that is inconsistent with the file
Report at the end with only a 1-3 sentence summary of what you changed
+6
View File
@@ -0,0 +1,6 @@
---
description: how to create and apply database migrations
alwaysApply: false
---
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
+66
View File
@@ -0,0 +1,66 @@
---
description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues
globs:
- "**/*.ts"
---
# OpenTelemetry Metrics Guidelines
When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**.
## What is Cardinality?
Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend.
## Rules
### DO use low-cardinality attributes:
- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW)
- **Booleans**: `hasFailures`, `streaming`, `success`
- **Bounded error codes**: A finite, controlled set of error types
- **Shard IDs**: When sharding is bounded (e.g., 0-15)
### DO NOT use high-cardinality attributes:
- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId`
- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount`
- **Timestamps**: `createdAt`, `startTime`
- **Free-form strings**: `errorMessage`, `taskName`, `queueName`
## Example
```typescript
// BAD - High cardinality
this.counter.add(1, {
envId: options.environmentId, // UUID - unbounded
itemCount: options.runCount, // Integer - unbounded
});
// GOOD - Low cardinality
this.counter.add(1, {
environment_type: options.environmentType, // Enum - 4 values
streaming: true, // Boolean - 2 values
});
```
## Prometheus Metric Naming
When metrics are exported via OTLP to Prometheus, the exporter automatically adds unit suffixes to metric names:
| OTel Metric Name | Unit | Prometheus Name |
|------------------|------|-----------------|
| `my_duration_ms` | `ms` | `my_duration_ms_milliseconds` |
| `my_counter` | counter | `my_counter_total` |
| `items_inserted` | counter | `items_inserted_inserts_total` |
| `batch_size` | histogram | `batch_size_items_bucket` |
Keep this in mind when writing Grafana dashboards or Prometheus queries—the metric names in Prometheus will differ from the names defined in code.
## Reference
See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes.
High cardinality metrics can cause:
- Memory bloat in metrics backends (Axiom, Prometheus, etc.)
- Slow queries and dashboard timeouts
- Increased costs (many backends charge per time series)
- Potential data loss or crashes at scale
+14 -3
View File
@@ -13,6 +13,11 @@ APP_ORIGIN=http://localhost:3030
ELECTRIC_ORIGIN=http://localhost:3060
NODE_ENV=development
# Clickhouse
CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_ENABLED=1
# Set this to UTC because Node.js uses the system timezone
TZ="UTC"
@@ -29,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
@@ -80,4 +85,10 @@ POSTHOG_PROJECT_KEY=
# These control the server-side internal telemetry
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
+102
View File
@@ -0,0 +1,102 @@
name: 🦋 Changesets PR
on:
push:
branches:
- main
paths:
- "packages/**"
- ".changeset/**"
- "package.json"
- "pnpm-lock.yaml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release-pr:
name: Create Release PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: github.repository == 'triggerdotdev/trigger.dev'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create release PR
id: changesets
uses: changesets/action@v1
with:
version: pnpm run changeset:version
commit: "chore: release"
title: "chore: release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update PR title with version
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
if [ -n "$PR_NUMBER" ]; then
git fetch origin changeset-release/main
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
fi
update-lockfile:
name: Update lockfile on release PR
runs-on: ubuntu-latest
needs: release-pr
permissions:
contents: write
steps:
- name: Checkout release branch
uses: actions/checkout@v4
with:
ref: changeset-release/main
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
- name: Commit and push lockfile
run: |
set -e
if git diff --quiet pnpm-lock.yaml; then
echo "No lockfile changes"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore: update lockfile for release"
git push origin changeset-release/main
fi
+70
View File
@@ -0,0 +1,70 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
claude_args: |
--model claude-opus-4-5-20251101
--allowedTools "Bash(pnpm:*),Bash(turbo:*),Bash(git:*),Bash(gh:*),Bash(npx:*),Bash(docker:*),Edit,MultiEdit,Read,Write,Glob,Grep,LS,Task"
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -2
View File
@@ -31,12 +31,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
+138
View File
@@ -0,0 +1,138 @@
name: 🧭 Helm Chart PR Prerelease
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "hosting/k8s/helm/**"
concurrency:
group: helm-prerelease-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
CHART_NAME: trigger
jobs:
lint-and-test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: "3.18.3"
- name: Build dependencies
run: helm dependency build ./hosting/k8s/helm/
- name: Extract dependency charts
run: |
cd ./hosting/k8s/helm/
for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done
- name: Lint Helm Chart
run: |
helm lint ./hosting/k8s/helm/
- name: Render templates
run: |
helm template test-release ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/values.yaml \
--output-dir ./helm-output
- name: Validate manifests
uses: docker://ghcr.io/yannh/kubeconform:v0.7.0
with:
entrypoint: "/kubeconform"
args: "-summary -output json ./helm-output"
prerelease:
needs: lint-and-test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: "3.18.3"
- name: Build dependencies
run: helm dependency build ./hosting/k8s/helm/
- name: Extract dependency charts
run: |
cd ./hosting/k8s/helm/
for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate prerelease version
id: version
run: |
BASE_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}')
PR_NUMBER=${{ github.event.pull_request.number }}
SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
PRERELEASE_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${SHORT_SHA}"
echo "version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Prerelease version: $PRERELEASE_VERSION"
- name: Update Chart.yaml with prerelease version
run: |
sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" ./hosting/k8s/helm/Chart.yaml
- name: Package Helm Chart
run: |
helm package ./hosting/k8s/helm/ --destination /tmp/
- name: Push Helm Chart to GHCR
run: |
VERSION="${{ steps.version.outputs.version }}"
CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz"
# Push to GHCR OCI registry
helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts"
- name: Find existing comment
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: "Helm Chart Prerelease Published"
- name: Create or update PR comment
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
### 🧭 Helm Chart Prerelease Published
**Version:** `${{ steps.version.outputs.version }}`
**Install:**
```bash
helm upgrade --install trigger \
oci://ghcr.io/${{ github.repository_owner }}/charts/trigger \
--version "${{ steps.version.outputs.version }}"
```
> ⚠️ This is a prerelease for testing. Do not use in production.
edit-mode: replace
+4
View File
@@ -29,3 +29,7 @@ jobs:
with:
package: cli-v3
secrets: inherit
sdk-compat:
uses: ./.github/workflows/sdk-compat.yml
secrets: inherit
+1
View File
@@ -1,6 +1,7 @@
name: 🚀 Publish Trigger.dev Docker
on:
workflow_dispatch:
workflow_call:
inputs:
image_tag:
+145 -48
View File
@@ -1,98 +1,195 @@
name: 🦋 Changesets Release
permissions:
contents: write
on:
push:
pull_request:
types: [closed]
branches:
- main
paths-ignore:
- "docs/**"
- "**.md"
- ".github/CODEOWNERS"
- ".github/ISSUE_TEMPLATE/**"
workflow_dispatch:
inputs:
type:
description: "Select release type"
required: true
type: choice
options:
- release
- prerelease
default: "prerelease"
ref:
description: "The ref (branch, tag, or SHA) to checkout and release from"
required: true
type: string
prerelease_tag:
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
required: false
type: string
default: "prerelease"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
release:
name: 🦋 Changesets Release
show-release-summary:
name: 📋 Release Summary
runs-on: ubuntu-latest
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'changeset-release/main'
steps:
- name: Show release summary
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
release:
name: 🚀 Release npm packages
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: write
packages: write
pull-requests: write
if: github.repository == 'triggerdotdev/trigger.dev'
id-token: write
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
(
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
)
outputs:
published: ${{ steps.changesets.outputs.published }}
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
published_package_version: ${{ steps.get_version.outputs.package_version }}
steps:
- name: ⬇️ Checkout repo
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
- name: ⎔ Setup pnpm
- name: Verify ref is on main
if: github.event_name == 'workflow_dispatch'
run: |
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
echo "Error: ref must be an ancestor of main (i.e., already merged)"
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: Setup node
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
# npm v11.5.1 or newer is required for OIDC support
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
- name: Setup npm 11.x for OIDC
run: npm install -g npm@11.6.4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
- name: Generate Prisma client
run: pnpm run generate
- name: 🏗️ Build
- name: Build
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: 🔎 Type check
- name: Type check
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
- name: 🔐 Setup npm auth
run: |
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
# This action has two responsibilities. The first time the workflow runs
# (initial push to the `main` branch) it will create a new branch and
# then open a PR with the related changes for the new version. After the
# PR is merged, the workflow will run again and this action will build +
# publish to npm.
- name: 🚀 PR / Publish
if: ${{ !env.ACT }}
- name: Publish
id: changesets
uses: changesets/action@v1
with:
version: pnpm run changeset:version
commit: "chore: Update version for release"
title: "chore: Update version for release"
publish: pnpm run changeset:release
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# - name: 🚀 PR / Publish (mock)
# if: ${{ env.ACT }}
# id: changesets
# run: |
# echo "published=true" >> "$GITHUB_OUTPUT"
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
- name: 📦 Get package version
- name: Show package version
if: steps.changesets.outputs.published == 'true'
id: get_version
run: |
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
- name: Create and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
set -e
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
# Trigger Docker builds directly via workflow_call since tags pushed with
# GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation).
publish-docker:
name: 🐳 Publish Docker images
needs: release
if: needs.release.outputs.published == 'true'
uses: ./.github/workflows/publish.yml
secrets: inherit
with:
image_tag: v${{ needs.release.outputs.published_package_version }}
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
prerelease:
name: 🧪 Prerelease
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: read
id-token: write
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
- name: Setup npm 11.x for OIDC
run: npm install -g npm@11.6.4
- name: Download deps
run: pnpm install --frozen-lockfile
- name: Generate Prisma Client
run: pnpm run generate
- name: Snapshot version
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Clean
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
- name: Build
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: Publish prerelease
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+178
View File
@@ -0,0 +1,178 @@
name: "🔌 SDK Compatibility Tests"
permissions:
contents: read
on:
workflow_call:
jobs:
node-compat:
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: ["20.20", "22.12"]
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
shell: bash
run: pnpm run build --filter '@trigger.dev/sdk^...'
- name: 🔨 Build SDK
shell: bash
run: pnpm run build --filter '@trigger.dev/sdk'
- name: 🧪 Run SDK Compatibility Tests
shell: bash
run: pnpm --filter @internal/sdk-compat-tests test
bun-compat:
name: "Bun Runtime"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 🥟 Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 🧪 Run Bun Compatibility Test
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
run: bun run test.ts
deno-compat:
name: "Deno Runtime"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 🦕 Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 🔗 Link node_modules for Deno fixture
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
run: ln -s ../../../../../node_modules node_modules
- name: 🧪 Run Deno Compatibility Test
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
run: deno run --allow-read --allow-env --allow-sys test.ts
cloudflare-compat:
name: "Cloudflare Workers"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 📥 Install Cloudflare fixture deps
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
run: pnpm install
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
run: npx wrangler deploy --dry-run --outdir dist
+4 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
@@ -35,6 +35,8 @@ jobs:
- name: 🔎 Type check
run: pnpm run typecheck
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: 🔎 Check exports
run: pnpm run check-exports
+15 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -72,6 +72,17 @@ jobs:
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
echo "Pre-pulling Docker images with authenticated session..."
docker pull postgres:14
docker pull clickhouse/clickhouse-server:25.4-alpine
docker pull redis:7-alpine
docker pull testcontainers/ryuk:0.11.0
docker pull electricsql/electric:1.2.4
echo "Image pre-pull complete"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -111,12 +122,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+15 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -72,6 +72,17 @@ jobs:
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
echo "Pre-pulling Docker images with authenticated session..."
docker pull postgres:14
docker pull clickhouse/clickhouse-server:25.4-alpine
docker pull redis:7-alpine
docker pull testcontainers/ryuk:0.11.0
docker pull electricsql/electric:1.2.4
echo "Image pre-pull complete"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -111,12 +122,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+15 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -72,6 +72,17 @@ jobs:
if: ${{ !env.DOCKERHUB_USERNAME }}
run: echo "DockerHub login skipped because secrets are not available."
- name: 🐳 Pre-pull testcontainer images
if: ${{ env.DOCKERHUB_USERNAME }}
run: |
echo "Pre-pulling Docker images with authenticated session..."
docker pull postgres:14
docker pull clickhouse/clickhouse-server:25.4-alpine
docker pull redis:7-alpine
docker pull testcontainers/ryuk:0.11.0
docker pull electricsql/electric:1.2.4
echo "Image pre-pull complete"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -119,12 +130,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.20.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+10 -7
View File
@@ -15,6 +15,9 @@ out/
dist
packages/**/dist
# vendored bundles (generated during build)
packages/**/src/**/vendor
# Tailwind
apps/**/styles/tailwind.css
packages/**/styles/tailwind.css
@@ -29,12 +32,10 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env.docker
.env
.env.*
.docker/*.env
.env.local
.env.development.local
.env.test.local
.env.production.local
!.env.example
# turbo
.turbo
@@ -63,5 +64,7 @@ apps/**/public/build
/packages/core/src/package.json
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
.claude
.mcp.log
**/.claude/settings.local.json
.mcp.log
.mcp.json
.cursor/debug.log
-5
View File
@@ -1,5 +0,0 @@
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
prefer-workspace-packages=true
update-notifier=false
side-effects-cache=false
+1 -1
View File
@@ -1 +1 @@
v20.11.1
v20.20.0
+2 -1
View File
@@ -6,5 +6,6 @@
"**/node_modules/**": true,
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": false
}
+1 -1
View File
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
See `ai/references/repo.md` for a more complete explanation of the workspaces.
## Development setup
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.20.0` are required).
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
3. Start the local services with Docker:
```bash
+303
View File
@@ -0,0 +1,303 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and Development Commands
This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`.
### Essential Commands
```bash
# Start Docker services (PostgreSQL, Redis, Electric)
pnpm run docker
# Run database migrations
pnpm run db:migrate
# Seed the database (required for reference projects)
pnpm run db:seed
# Build packages (required before running)
pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk
# Run webapp in development mode (http://localhost:3030)
pnpm run dev --filter webapp
# Build and watch for changes (CLI and packages)
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
```
### Testing
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
```bash
# Run all tests for a package
pnpm run test --filter webapp
# Run a single test file (preferred - cd into directory first)
cd internal-packages/run-engine
pnpm run test ./src/engine/tests/ttl.test.ts --run
# May need to build dependencies first
pnpm run build --filter @internal/run-engine
```
Test files go next to source files (e.g., `MyService.ts``MyService.test.ts`).
#### Testcontainers for Redis/PostgreSQL
```typescript
import { redisTest, postgresTest, containerTest } from "@internal/testcontainers";
// Redis only
redisTest("should use redis", async ({ redisOptions }) => {
/* ... */
});
// PostgreSQL only
postgresTest("should use postgres", async ({ prisma }) => {
/* ... */
});
// Both Redis and PostgreSQL
containerTest("should use both", async ({ prisma, redisOptions }) => {
/* ... */
});
```
### Changesets
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
```bash
pnpm run changeset:add
```
- Default to **patch** for bug fixes and minor changes
- Confirm with maintainers before selecting **minor** (new features)
- **Never** select major (breaking changes) without explicit approval
## Architecture Overview
### Apps
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server.
- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes.
### Public Packages
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK
- **packages/cli-v3** (`trigger.dev`): CLI package
- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root).
- **packages/build**: Build extensions and types
- **packages/react-hooks**: React hooks for realtime and triggering
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system
### Internal Packages
- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema
- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations
- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management
- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities
- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers
- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker)
### Reference Projects
The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs.
## Webapp Development
### Key Locations
- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts`
- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts`
- Prisma setup: `apps/webapp/app/db.server.ts`
- Run engine config: `apps/webapp/app/v3/runEngine.server.ts`
- Services: `apps/webapp/app/v3/services/**/*.server.ts`
- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts`
- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts`
### Environment Variables
Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly.
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern:
- `realtimeClient.server.ts` (testable service)
- `realtimeClientGlobal.server.ts` (configuration)
### Legacy vs Run Engine 2.0
The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work.
## Docker Image Guidelines
When updating Docker image references in `docker/Dockerfile` or other container files:
- **Always use multiplatform/index digests**, not architecture-specific digests
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
## Database Migrations (PostgreSQL)
1. Edit `internal-packages/database/prisma/schema.prisma`
2. Create migration:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column"
```
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
- `_BackgroundWorkerToBackgroundWorkerFile`
- `_BackgroundWorkerToTaskQueue`
- `_TaskRunToTaskRunTag`
- `_WaitpointRunConnections`
- `_completedWaitpoints`
- `SecretStore_key_idx`
- Various `TaskRun` indexes unless you added them
4. Apply migration:
```bash
pnpm run db:migrate:deploy && pnpm run generate
```
### Index Migration Rules
- Indexes **must use CONCURRENTLY** to avoid table locks
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
## ClickHouse Migrations
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
2. Use Goose markers:
```sql
-- +goose Up
ALTER TABLE trigger_dev.your_table
ADD COLUMN new_column String DEFAULT '';
-- +goose Down
ALTER TABLE trigger_dev.your_table
DROP COLUMN new_column;
```
Follow naming conventions in `internal-packages/clickhouse/README.md`:
- `raw_` prefix for input tables
- `_v1`, `_v2` suffixes for versioning
- `_mv_v1` suffix for materialized views
## Writing Trigger.dev Tasks
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
```typescript
import { task } from "@trigger.dev/sdk";
// Every task must be exported
export const myTask = task({
id: "my-task", // Unique ID
run: async (payload: { message: string }) => {
// Task logic - no timeouts
},
});
```
### SDK Documentation Rules
The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`.
- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing
- `rules/4.1.0/` - Realtime streams v2, updated config
- `rules/4.0.0/` - Base v4 SDK documentation
When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions.
### Claude Code Skill
The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes:
- `SKILL.md` - Core instructions and patterns
- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config
Keep the skill in sync with the latest rules version when SDK features change.
## Testing with hello-world Reference Project
First-time setup:
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i`
3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
Running:
```bash
cd references/hello-world
pnpm exec trigger dev # or with --log-level debug
```
## Local Task Testing Workflow
This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes.
### Step 1: Start Webapp in Background
```bash
# Run from repo root with run_in_background: true
pnpm run dev --filter webapp
```
Verify webapp is running:
```bash
curl -s http://localhost:3030/healthcheck # Should return 200
```
### Step 2: Start Trigger Dev in Background
```bash
# Run from hello-world directory with run_in_background: true
cd references/hello-world && pnpm exec trigger dev
```
The worker will build and register tasks. Check output for "Local worker ready [node]" message.
### Step 3: Trigger and Monitor Tasks via MCP
Use the Trigger.dev MCP tools to interact with tasks:
```
# Get current worker and registered tasks
mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev")
# Trigger a task
mcp__trigger__trigger_task(
projectRef: "proj_rrkpdguyagvsoktglnod",
environment: "dev",
taskId: "hello-world",
payload: {"message": "Hello from Claude"}
)
# List runs to see status
mcp__trigger__list_runs(
projectRef: "proj_rrkpdguyagvsoktglnod",
environment: "dev",
taskIdentifier: "hello-world",
limit: 5
)
```
### Step 4: Monitor Execution
- Check trigger dev output file for real-time execution logs
- Successful runs show: `Task | Run ID | Success (Xms)`
- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
### Key Project Refs
- hello-world: `proj_rrkpdguyagvsoktglnod`
+6 -6
View File
@@ -14,8 +14,8 @@ branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version 20.11.1
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
- [Node.js](https://nodejs.org/en) version 20.20.0
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
@@ -34,9 +34,9 @@ branch are tagged into a release periodically.
```
cd trigger.dev
```
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
3. Ensure you are on the correct version of Node.js (20.20.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
5. Install the required packages using pnpm.
```
@@ -92,7 +92,7 @@ We use the `<root>/references/hello-world` subdirectory as a staging ground for
First, make sure you are running the webapp according to the instructions above. Then:
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
1. Visit http://localhost:3030 in your browser and create a new project called "hello-world".
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
@@ -127,7 +127,7 @@ pnpm exec trigger deploy --profile local
### Running
The following steps should be followed any time you start working on a new feature you want to test in v3:
The following steps should be followed any time you start working on a new feature you want to test:
1. Make sure the webapp is running on localhost:3030
+2 -1
View File
@@ -4,7 +4,7 @@
### Build and deploy fullymanaged AI agents and workflows
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
@@ -13,6 +13,7 @@
[![Twitter Follow](https://img.shields.io/twitter/follow/triggerdotdev?style=social)](https://twitter.com/triggerdotdev)
[![Discord](https://img.shields.io/discord/1066956501299777596?logo=discord&logoColor=white&color=7289da)](https://discord.gg/nkqV9xBYWy)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/triggerdotdev/trigger.dev)
[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
</div>
+121
View File
@@ -0,0 +1,121 @@
## Creating and applying migrations
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
Edit the `schema.prisma` file to add or modify the schema.
Create a new migration file but don't apply it yet:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
```
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
All the following lines should be removed:
```sql
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
Leaving only this:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
```
After editing the migration file, apply the migration:
```bash
cd internal-packages/database
pnpm run db:migrate:deploy && pnpm run generate
```
+1 -1
View File
@@ -1,6 +1,6 @@
## Repo Overview
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
## Apps
+1 -1
View File
@@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter coordinator build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle
FROM alpine AS cri-tools
+1 -1
View File
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter docker-provider build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle
FROM base AS runner
+1 -1
View File
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter kubernetes-provider build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle
FROM base AS runner
+3 -3
View File
@@ -5,7 +5,7 @@ WORKDIR /app
FROM node-22-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
FROM node-22-alpine AS base
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable && corepack prepare --activate
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
FROM base AS deps-fetcher
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
RUN pnpm run generate && \
pnpm run --filter supervisor... build&& \
pnpm deploy --filter=supervisor --prod /prod/supervisor
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
FROM base AS runner
+27 -1
View File
@@ -57,7 +57,7 @@ const Env = z.object({
RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
// Docker settings
DOCKER_API_VERSION: z.string().default("v1.41"),
DOCKER_API_VERSION: z.string().optional(),
DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64
DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true),
DOCKER_REGISTRY_USERNAME: z.string().optional(),
@@ -89,7 +89,33 @@ const Env = z.object({
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
// Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO
KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(),
// Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO
KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(),
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
// Project affinity settings - pods from the same project prefer the same node
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
+8
View File
@@ -244,6 +244,12 @@ class ManagedSupervisor {
}
try {
if (!message.deployment.friendlyId) {
// mostly a type guard, deployments always exists for deployed environments
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
throw new Error("Deployment is missing");
}
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
envId: message.environment.id,
@@ -252,6 +258,8 @@ class ManagedSupervisor {
machine: message.run.machine,
orgId: message.organization.id,
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -25,6 +25,7 @@ export class FailedPodHandler {
private readonly informer: Informer<V1Pod>;
private readonly reconnectIntervalMs: number;
private reconnecting = false;
// Metrics
private readonly register: Registry;
@@ -250,21 +251,48 @@ export class FailedPodHandler {
}
private makeOnError(informerName: string) {
return () => this.onError(informerName);
return (err?: unknown) => this.onError(informerName, err);
}
private async onError(informerName: string) {
private async onError(informerName: string, err?: unknown) {
if (!this.isRunning) {
this.logger.warn("onError: informer not running");
return;
}
this.logger.error("error event fired", { informerName });
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Guard against multiple simultaneous reconnections
if (this.reconnecting) {
this.logger.debug("onError: reconnection already in progress, skipping", {
informerName,
});
return;
}
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
this.reconnecting = true;
try {
const error = err instanceof Error ? err : undefined;
this.logger.error("error event fired", {
informerName,
error: error?.message,
errorType: error?.name,
});
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
} catch (handlerError) {
const error = handlerError instanceof Error ? handlerError : undefined;
this.logger.error("onError: reconnection attempt failed", {
informerName,
error: error?.message,
errorType: error?.name,
errorStack: error?.stack,
});
} finally {
this.reconnecting = false;
}
}
private makeOnConnect(informerName: string) {
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`TRIGGER_ENV_ID=${opts.envId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
@@ -4,7 +4,12 @@ import {
type WorkloadManagerCreateOptions,
type WorkloadManagerOptions,
} from "./types.js";
import type { EnvironmentType, MachinePreset, PlacementTag } from "@trigger.dev/core/v3";
import type {
EnvironmentType,
MachinePreset,
MachinePresetName,
PlacementTag,
} from "@trigger.dev/core/v3";
import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
@@ -14,6 +19,26 @@ type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
};
const cpuRequestRatioByMachinePreset: Record<MachinePresetName, number | undefined> = {
micro: env.KUBERNETES_CPU_REQUEST_RATIO_MICRO,
"small-1x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X,
"small-2x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X,
"medium-1x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X,
"medium-2x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X,
"large-1x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X,
"large-2x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X,
};
const memoryRequestRatioByMachinePreset: Record<MachinePresetName, number | undefined> = {
micro: env.KUBERNETES_MEMORY_REQUEST_RATIO_MICRO,
"small-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X,
"small-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X,
"medium-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X,
"medium-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X,
"large-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X,
"large-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X,
};
export class KubernetesWorkloadManager implements WorkloadManager {
private readonly logger = new SimpleStructuredLogger("kubernetes-workload-provider");
private k8s: K8sApi;
@@ -95,6 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
affinity: this.#getAffinity(opts.machine, opts.projectId),
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
@@ -123,6 +149,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
name: "TRIGGER_ENV_ID",
value: opts.envId,
},
{
name: "TRIGGER_DEPLOYMENT_ID",
value: opts.deploymentFriendlyId,
},
{
name: "TRIGGER_DEPLOYMENT_VERSION",
value: opts.deploymentVersion,
},
{
name: "TRIGGER_SNAPSHOT_ID",
value: opts.snapshotFriendlyId,
@@ -274,6 +308,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
restartPolicy: "Never",
automountServiceAccountToken: false,
imagePullSecrets: this.getImagePullSecrets(),
...(env.KUBERNETES_SCHEDULER_NAME
? {
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
}
: {}),
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
? {
nodeSelector: {
@@ -302,12 +341,16 @@ export class KubernetesWorkloadManager implements WorkloadManager {
envtype: this.#envTypeToLabelValue(opts.envType),
org: opts.orgId,
project: opts.projectId,
machine: opts.machine.name,
};
}
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
const cpuRequest = preset.cpu * this.cpuRequestRatio;
const memoryRequest = preset.memory * this.memoryRequestRatio;
const cpuRatio = cpuRequestRatioByMachinePreset[preset.name] ?? this.cpuRequestRatio;
const memoryRatio = memoryRequestRatioByMachinePreset[preset.name] ?? this.memoryRequestRatio;
const cpuRequest = preset.cpu * cpuRatio;
const memoryRequest = preset.memory * memoryRatio;
// Clamp between min and max
const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu);
@@ -342,4 +385,91 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
};
}
#isLargeMachine(preset: MachinePreset): boolean {
return preset.name.startsWith("large-");
}
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
const nodeAffinity = this.#getNodeAffinityRules(preset);
const podAffinity = this.#getProjectPodAffinity(projectId);
if (!nodeAffinity && !podAffinity) {
return undefined;
}
return {
...(nodeAffinity && { nodeAffinity }),
...(podAffinity && { podAffinity }),
};
}
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined {
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
return undefined;
}
if (this.#isLargeMachine(preset)) {
// soft preference for the large-machine pool, falls back to standard if unavailable
return {
preferredDuringSchedulingIgnoredDuringExecution: [
{
weight: 100,
preference: {
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "In",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
},
],
};
}
// not schedulable in the large-machine pool
return {
requiredDuringSchedulingIgnoredDuringExecution: {
nodeSelectorTerms: [
{
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "NotIn",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
],
},
};
}
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
return undefined;
}
return {
preferredDuringSchedulingIgnoredDuringExecution: [
{
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
podAffinityTerm: {
labelSelector: {
matchExpressions: [
{
key: "project",
operator: "In",
values: [projectId],
},
],
},
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
},
},
],
};
}
}
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
envType: EnvironmentType;
orgId: string;
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
+1
View File
@@ -0,0 +1 @@
../../.env
+2 -1
View File
@@ -9,7 +9,8 @@ node_modules
/app/styles/tailwind.css
# Ensure the .env symlink is not removed by accident
!.env
# Storybook build outputs
build-storybook.log
@@ -0,0 +1,71 @@
export function AbacusIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_16909_120578)">
<path
d="M4 3V21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20 21V3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 5L8 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M14 5L14 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15 10L15 11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 10L9 11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 15L12 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 15L8 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21H21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</svg>
);
}
@@ -0,0 +1,22 @@
export function ArrowTopRightBottomLeftIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.8258 10.5L20.125 5.20083V8.5625C20.125 9.08027 20.5447 9.5 21.0625 9.5C21.5803 9.5 22 9.08027 22 8.5625V2.9375C22 2.41973 21.5803 2 21.0625 2H15.4375C14.9197 2 14.5 2.41973 14.5 2.9375C14.5 3.45527 14.9197 3.875 15.4375 3.875H18.7992L13.5 9.17417C13.1339 9.54029 13.1339 10.1339 13.5 10.5C13.8661 10.8661 14.4597 10.8661 14.8258 10.5Z"
fill="currentColor"
/>
<path
d="M2 21.0625V15.4375C2 14.9197 2.41973 14.5 2.9375 14.5C3.45527 14.5 3.875 14.9197 3.875 15.4375V18.7992L9.17417 13.5C9.54029 13.1339 10.1339 13.1339 10.5 13.5C10.8661 13.8661 10.8661 14.4597 10.5 14.8258L5.20083 20.125H8.5625C9.08027 20.125 9.5 20.5447 9.5 21.0625C9.5 21.5803 9.08027 22 8.5625 22H2.9375C2.69757 22 2.45765 21.9085 2.27459 21.7254C2.1847 21.6355 2.11689 21.5319 2.07114 21.4214C2.0253 21.3108 2 21.1896 2 21.0625Z"
fill="currentColor"
/>
<path
d="M14.8258 10.5L20.125 5.20083V10C20.125 10.5178 20.5447 10.9375 21.0625 10.9375C21.5803 10.9375 22 10.5178 22 10V2.9375C22 2.41973 21.5803 2 21.0625 2H14C13.4822 2 13.0625 2.41973 13.0625 2.9375C13.0625 3.45527 13.4822 3.875 14 3.875H18.7992L13.5 9.17417C13.1339 9.54029 13.1339 10.1339 13.5 10.5C13.8661 10.8661 14.4597 10.8661 14.8258 10.5Z"
fill="currentColor"
/>
<path
d="M2 21.0625V13.9375C2 13.4197 2.41973 13 2.9375 13C3.45527 13 3.875 13.4197 3.875 13.9375V18.7992L9.17417 13.5C9.54029 13.1339 10.1339 13.1339 10.5 13.5C10.8661 13.8661 10.8661 14.4597 10.5 14.8258L5.20083 20.125H10.0625C10.5803 20.125 11 20.5447 11 21.0625C11 21.5803 10.5803 22 10.0625 22H2.9375C2.69757 22 2.45765 21.9085 2.27459 21.7254C2.1847 21.6355 2.11689 21.5319 2.07114 21.4214C2.0253 21.3108 2 21.1896 2 21.0625Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,13 @@
export function ChevronExtraSmallDown({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 6L9.75926 12.1142C9.36016 12.5798 8.63984 12.5798 8.24074 12.1142L3 6"
stroke="currentColor"
strokeWidth="2"
strokeMiterlimit="1.00244"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,13 @@
export function ChevronExtraSmallUp({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 12L8.24074 5.8858C8.63984 5.42019 9.36016 5.42019 9.75926 5.8858L15 12"
stroke="currentColor"
strokeWidth="2"
strokeMiterlimit="1.00244"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,13 @@
export function ConcurrencyIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
</svg>
);
}
@@ -0,0 +1,30 @@
export function ListBulletIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 5H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 12H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 19H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="4" cy="5" r="1" fill="currentColor" />
<circle cx="4" cy="12" r="1" fill="currentColor" />
<circle cx="4" cy="19" r="1" fill="currentColor" />
</svg>
);
}
+66
View File
@@ -0,0 +1,66 @@
export function LogsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="4" cy="10" r="1" fill="currentColor" />
<circle cx="4" cy="5" r="1" fill="currentColor" />
<circle cx="4" cy="14" r="1" fill="currentColor" />
<circle cx="4" cy="19" r="1" fill="currentColor" />
<path
d="M7 9.75L10 9.75"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 5L10 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 14.25H10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 19H10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 5H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 9.75H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 14.25H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 19H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,27 @@
export function MoveToBottomIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 15L12 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 12.5L12 17L16.5 12.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,20 @@
export function SnakedArrowIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 5H16C17.6569 5 19 6.34315 19 8L19 8.5C19 10.1569 17.6569 11.5 16 11.5H8C6.34314 11.5 5 12.8431 5 14.5L5 15C4.99999 16.6569 6.34314 18 8 18H18.634"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 21L19 18L16 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,10 @@
export function StreamsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 19 5.01155 17 8 17C10.9885 17 13 18.9973 16 18.9973C19 18.9973 21 17 21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M3 13.0001C3 13.0001 5.01155 11 8 11C10.9885 11 13 13 16 13C19 13 21 11.0001 21 11.0001" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M3 7C3 7 5.01155 5 8 5C10.9885 5 13 6.9973 16 6.9973C19 6.9973 21 5 21 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
@@ -0,0 +1,22 @@
export function GoogleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
fill="#4285F4"
/>
<path
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
fill="#34A853"
/>
<path
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
fill="#FBBC02"
/>
<path
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
fill="#EA4335"
/>
</svg>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "~/utils/cn";
import { Badge } from "./primitives/Badge";
import { SimpleTooltip } from "./primitives/Tooltip";
export function AlphaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Alpha
</Badge>
}
content="This feature is in Alpha."
disableHoverableContent
/>
);
}
export function AlphaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<AlphaBadge />
</>
);
}
+39 -25
View File
@@ -5,6 +5,7 @@ import {
HandThumbUpIcon,
StopIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
return routeMatch?.kapa.websiteId;
}
export function AskAI() {
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -54,21 +55,23 @@ export function AskAI() {
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
disabled
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
}
>
{() => <AskAIProvider websiteId={websiteId} />}
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
</ClientOnly>
);
}
type AskAIProviderProps = {
websiteId: string;
isCollapsed?: boolean;
};
function AskAIProvider({ websiteId }: AskAIProviderProps) {
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
@@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
}}
botProtectionMechanism="hcaptcha"
>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
>
<AISparkleIcon className="size-5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
Ask AI
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
fullWidth={isCollapsed}
className={cn("h-full", isCollapsed && "justify-center")}
>
<AISparkleIcon className="size-5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="flex items-center gap-2 text-xs"
>
Ask AI
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
+148 -132
View File
@@ -52,6 +52,13 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export function HasNoTasksDev() {
return (
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex min-w-0 items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
<Header1 className="truncate">Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
+2 -10
View File
@@ -1,11 +1,10 @@
import { HomeIcon } from "@heroicons/react/20/solid";
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
import { motion } from "framer-motion";
import { friendlyErrorDisplay } from "~/utils/httpErrors";
import { LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import Spline from "@splinetool/react-spline";
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
import { type ReactNode } from "react";
type ErrorDisplayOptions = {
@@ -57,14 +56,7 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
{button ? button.title : "Go to homepage"}
</LinkButton>
</div>
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
>
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
</motion.div>
<TriggerRotatingLogo />
</div>
);
}
+23 -3
View File
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
import { type ReactNode, useEffect, useState } from "react";
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
type FeedbackProps = {
button: ReactNode;
defaultValue?: FeedbackType;
onOpenChange?: (open: boolean) => void;
};
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
const [open, setOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const lastSubmission = useActionData();
const navigation = useNavigation();
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
}
}, [navigation, form]);
// Handle URL param functionality
useEffect(() => {
const open = searchParams.get("feedbackPanel");
if (open) {
setType(open as FeedbackType);
setOpen(true);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("feedbackPanel");
setSearchParams(next);
}
}, [searchParams]);
const handleOpenChange = (value: boolean) => {
setOpen(value);
onOpenChange?.(value);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent>
<DialogHeader>Contact us</DialogHeader>
@@ -0,0 +1,63 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
export function LogLevelTooltipInfo() {
return (
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
<div>
<Header3>Log Levels</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Structured logging helps you debug and monitor your tasks.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-blue-400">Info</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-warning">Warn</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-error">Error</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-charcoal-400">Debug</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
<div className="border-t border-charcoal-700 pt-4">
<Header3>Tracing & Spans</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Automatically track the flow of your code through task triggers, attempts, and HTTP
requests. Create custom traces to monitor specific operations.
</Paragraph>
</div>
<LinkButton
to="https://trigger.dev/docs/logging#tracing-and-spans"
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
);
}
+47 -8
View File
@@ -1,18 +1,17 @@
import { Keyboard } from "lucide-react";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetTrigger
} from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
import { Button } from "./primitives/Buttons";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
export function Shortcuts() {
return (
@@ -26,8 +25,8 @@ export function Shortcuts() {
fullWidth
textAlignLeft
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
className="gap-x-0 pl-0.5"
iconSpacing="gap-x-0.5"
className="gap-x-0 pl-1.5"
iconSpacing="gap-x-1.5"
>
Shortcuts
</Button>
@@ -77,11 +76,16 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Ask AI">
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter">
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Toggle side menu">
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select filter">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
@@ -134,6 +138,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to next/previous run">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
@@ -154,6 +162,37 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Logs page</Header3>
<Shortcut name="Filter by task">
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter by run ID">
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter by level">
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select log level">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
to
</Paragraph>
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Close detail panel">
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Details tab">
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Run tab">
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="View full run">
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
<Shortcut name="New schedule">
@@ -0,0 +1,75 @@
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
declare global {
namespace JSX {
interface IntrinsicElements {
"spline-viewer": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
url?: string;
"loading-anim-type"?: string;
},
HTMLElement
>;
}
}
interface Window {
__splineLoader?: Promise<void>;
}
}
export function TriggerRotatingLogo() {
const [isSplineReady, setIsSplineReady] = useState(false);
useEffect(() => {
// Already registered from a previous render
if (customElements.get("spline-viewer")) {
setIsSplineReady(true);
return;
}
// Another mount already started loading - share the same promise
if (window.__splineLoader) {
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
return;
}
// First mount: create script and shared loader promise
const script = document.createElement("script");
script.type = "module";
// Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys
script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js";
window.__splineLoader = new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject();
});
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
document.head.appendChild(script);
// Intentionally no cleanup: once the custom element is registered globally,
// removing the script would break re-mounts while providing no benefit
}, []);
if (!isSplineReady) {
return null;
}
return (
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
>
<spline-viewer
loading-anim-type="spinner-small-light"
url="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode"
style={{ width: "100%", height: "100%" }}
/>
</motion.div>
);
}
@@ -22,6 +22,7 @@ export function UserAvatar({
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
) : (
@@ -0,0 +1,434 @@
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
// Lazy load streamdown components to avoid SSR issues
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
| { type: "tool_call"; tool: string; args: unknown }
| { type: "time_filter"; filter: AITimeFilter }
| { type: "result"; success: true; query: string; timeFilter?: AITimeFilter }
| { type: "result"; success: false; error: string };
export type AIQueryMode = "new" | "edit";
interface AIQueryInputProps {
onQueryGenerated: (query: string) => void;
/** Called when the AI sets a time filter - updates URL search params */
onTimeFilterChange?: (filter: AITimeFilter) => void;
/** Set this to a prompt to auto-populate and immediately submit */
autoSubmitPrompt?: string;
/** Change this to force re-submission even if prompt is the same */
autoSubmitKey?: number;
/** Get the current query in the editor (used for edit mode) */
getCurrentQuery?: () => string;
}
export function AIQueryInput({
onQueryGenerated,
onTimeFilterChange,
autoSubmitPrompt,
autoSubmitKey,
getCurrentQuery,
}: AIQueryInputProps) {
const [prompt, setPrompt] = useState("");
const [mode, setMode] = useState<AIQueryMode>("new");
const [isLoading, setIsLoading] = useState(false);
const [thinking, setThinking] = useState("");
const [error, setError] = useState<string | null>(null);
const [showThinking, setShowThinking] = useState(false);
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
// Can only use edit mode if there's a current query
const canEdit = Boolean(getCurrentQuery?.()?.trim());
// If mode is edit but there's no current query, switch to new
useEffect(() => {
if (mode === "edit" && !canEdit) {
setMode("new");
}
}, [mode, canEdit]);
const submitQuery = useCallback(
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
if (!queryPrompt.trim() || isLoading) return;
const currentQuery = getCurrentQuery?.();
if (submitMode === "edit" && !currentQuery?.trim()) return;
setIsLoading(true);
setThinking("");
setError(null);
setShowThinking(true);
setLastResult(null);
// Abort any existing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const formData = new FormData();
formData.append("prompt", queryPrompt);
formData.append("mode", submitMode);
if (submitMode === "edit" && currentQuery) {
formData.append("currentQuery", currentQuery);
}
const response = await fetch(resourcePath, {
method: "POST",
body: formData,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
setError(errorData.error || "Failed to generate query");
setIsLoading(false);
setLastResult("error");
return;
}
const reader = response.body?.getReader();
if (!reader) {
setError("No response stream");
setIsLoading(false);
setLastResult("error");
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete events from buffer
const lines = buffer.split("\n\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
}
}
// Process any remaining data
if (buffer.startsWith("data: ")) {
try {
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Request was aborted, ignore
return;
}
setError(err instanceof Error ? err.message : "An error occurred");
setLastResult("error");
} finally {
setIsLoading(false);
}
},
[isLoading, resourcePath, mode, getCurrentQuery]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "tool_call":
if (event.tool === "setTimeFilter") {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Setting time filter...")) {
return prev;
}
return prev + `\nSetting time filter...\n`;
});
} else {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Validating query...")) {
return prev;
}
return prev + `\nValidating query...\n`;
});
}
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
onTimeFilterChange?.(event.filter);
break;
case "result":
if (event.success) {
// Apply time filter if included in result (backup in case time_filter event was missed)
if (event.timeFilter) {
onTimeFilterChange?.(event.timeFilter);
}
onQueryGenerated(event.query);
setPrompt("");
setLastResult("success");
// Keep thinking visible to show what happened
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onQueryGenerated, onTimeFilterChange]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
submitQuery(prompt);
},
[prompt, submitQuery]
);
// Auto-submit when autoSubmitPrompt or autoSubmitKey changes
useEffect(() => {
if (!autoSubmitPrompt || !autoSubmitPrompt.trim() || isLoading) {
return;
}
const last = lastAutoSubmitRef.current;
const isDifferent =
last === null || autoSubmitPrompt !== last.prompt || autoSubmitKey !== last.key;
if (isDifferent) {
lastAutoSubmitRef.current = { prompt: autoSubmitPrompt, key: autoSubmitKey };
setPrompt(autoSubmitPrompt);
submitQuery(autoSubmitPrompt);
}
}, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Auto-hide error after delay
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 15000);
return () => clearTimeout(timer);
}
}, [error]);
return (
<div className="flex flex-col gap-3">
{/* Gradient border wrapper like the schedules AI input */}
<div
className="rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-[5px] bg-background-bright">
<form onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
name="prompt"
placeholder={
mode === "edit"
? "e.g. add a filter for failed runs, change the limit to 50"
: "e.g. show me failed runs from the last 7 days"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isLoading}
rows={8}
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-2 px-2 pb-2">
{isLoading ? (
<Button
type="button"
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-1.5"
iconSpacing="gap-1.5"
>
{mode === "edit" ? "Editing..." : "Generating..."}
</Button>
) : (
<>
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim()}
LeadingIcon={PlusIcon}
iconSpacing="gap-1.5"
onClick={() => {
setMode("new");
submitQuery(prompt, "new");
}}
>
New query
</Button>
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim() || !canEdit}
LeadingIcon={PencilSquareIcon}
className={cn(!canEdit && "opacity-50")}
iconSpacing="gap-2"
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
onClick={() => {
setMode("edit");
submitQuery(prompt, "edit");
}}
>
Edit query
</Button>
</>
)}
</div>
</form>
</div>
</div>
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
{error}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Thinking panel - stays visible after completion */}
<AnimatePresence>
{showThinking && thinking && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 0.3)",
foreground: "rgba(99, 102, 241, 1)",
}}
className="size-3"
/>
) : lastResult === "success" ? (
<div className="size-3 rounded-full bg-success" />
) : lastResult === "error" ? (
<div className="size-3 rounded-full bg-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking..."
: lastResult === "success"
? "Query generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,592 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import { Button } from "../primitives/Buttons";
export type ChartType = "bar" | "line";
export type SortDirection = "asc" | "desc";
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
export interface ChartConfiguration {
chartType: ChartType;
xAxisColumn: string | null;
yAxisColumns: string[];
groupByColumn: string | null;
stacked: boolean;
sortByColumn: string | null;
sortDirection: SortDirection;
aggregation: AggregationType;
}
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
xAxisColumn: null,
yAxisColumns: [],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
};
interface ChartConfigPanelProps {
columns: OutputColumnMetadata[];
config: ChartConfiguration;
onChange: (config: ChartConfiguration) => void;
className?: string;
}
// Type detection helpers
function isNumericType(type: string): boolean {
return (
type.startsWith("Int") ||
type.startsWith("UInt") ||
type.startsWith("Float") ||
type.startsWith("Decimal") ||
type.startsWith("Nullable(Int") ||
type.startsWith("Nullable(UInt") ||
type.startsWith("Nullable(Float") ||
type.startsWith("Nullable(Decimal")
);
}
function isDateTimeType(type: string): boolean {
return (
type === "DateTime" ||
type === "DateTime64" ||
type === "Date" ||
type === "Date32" ||
type.startsWith("DateTime64(") ||
type.startsWith("Nullable(DateTime") ||
type.startsWith("Nullable(Date")
);
}
function isStringType(type: string): boolean {
return (
type === "String" ||
type === "LowCardinality(String)" ||
type === "Nullable(String)" ||
type.startsWith("Enum") ||
type.startsWith("FixedString")
);
}
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
// Categorize columns by type
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
const numeric: OutputColumnMetadata[] = [];
const dateTime: OutputColumnMetadata[] = [];
const categorical: OutputColumnMetadata[] = [];
for (const col of columns) {
if (isNumericType(col.type)) {
numeric.push(col);
}
if (isDateTimeType(col.type)) {
dateTime.push(col);
}
if (isStringType(col.type) || isDateTimeType(col.type)) {
categorical.push(col);
}
}
return {
numericColumns: numeric,
dateTimeColumns: dateTime,
categoricalColumns: categorical,
allColumns: columns,
};
}, [columns]);
// Create a stable key from column names and types to detect actual changes
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
// Use refs to access current config/onChange without adding them as dependencies
const configRef = useRef(config);
const onChangeRef = useRef(onChange);
useEffect(() => {
configRef.current = config;
onChangeRef.current = onChange;
});
// Auto-select defaults when columns change
useEffect(() => {
if (columns.length === 0) return;
const currentConfig = configRef.current;
let needsUpdate = false;
const updates: Partial<ChartConfiguration> = {};
// Auto-select X-axis (prefer datetime, then first categorical)
if (!currentConfig.xAxisColumn) {
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
if (defaultX) {
updates.xAxisColumn = defaultX.name;
needsUpdate = true;
}
}
// Auto-select Y-axis (first numeric column)
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
updates.yAxisColumns = [numericColumns[0].name];
needsUpdate = true;
}
// Determine the effective x-axis column (either existing or newly selected)
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
if (
effectiveXAxis &&
!currentConfig.sortByColumn &&
dateTimeColumns.some((col) => col.name === effectiveXAxis)
) {
updates.sortByColumn = effectiveXAxis;
updates.sortDirection = "asc";
needsUpdate = true;
}
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
onChange({ ...config, ...updates });
},
[config, onChange]
);
// X-axis options: prefer datetime and string columns at the top
const xAxisOptions = useMemo(() => {
const preferred = [
...dateTimeColumns,
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
];
const preferredNames = new Set(preferred.map((c) => c.name));
const other = allColumns.filter((c) => !preferredNames.has(c.name));
const options: Array<{ value: string; label: string; type: string }> = [];
for (const col of preferred) {
options.push({ value: col.name, label: col.name, type: col.type });
}
for (const col of other) {
options.push({ value: col.name, label: col.name, type: col.type });
}
return options;
}, [allColumns, dateTimeColumns, categoricalColumns]);
// Y-axis options: numeric columns only
const yAxisOptions = useMemo(() => {
return numericColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
}, [numericColumns]);
// Aggregation options
const aggregationOptions = [
{ value: "sum", label: "Sum" },
{ value: "avg", label: "Average" },
{ value: "count", label: "Count" },
{ value: "min", label: "Min" },
{ value: "max", label: "Max" },
];
// Group by options: categorical columns (excluding selected X axis)
const groupByOptions = useMemo(() => {
const options = categoricalColumns
.filter((col) => col.name !== config.xAxisColumn)
.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [categoricalColumns, config.xAxisColumn]);
// Sort by options: all columns
const sortByOptions = useMemo(() => {
const options = allColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [allColumns]);
if (columns.length === 0) {
return (
<div className={cn("flex items-center justify-center p-4", className)}>
<Paragraph variant="small" className="text-text-dimmed">
Run a query to configure the chart
</Paragraph>
</div>
);
}
return (
<div className={cn("flex flex-col gap-2 p-2", className)}>
{/* Chart Type */}
<div className="flex flex-col gap-3">
<ConfigField label="Type">
<div className="flex items-center">
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-r-none border-b pl-1 pr-2",
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "bar" })}
LeadingIcon={BarChart}
leadingIconClassName={
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
Bar
</span>
</Button>
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-l-none border-b pl-1 pr-2",
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "line" })}
LeadingIcon={LineChart}
leadingIconClassName={
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
>
Line
</span>
</Button>
</div>
</ConfigField>
</div>
<div className="flex flex-col gap-2">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
value={config.xAxisColumn ?? ""}
setValue={(value) => {
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
// Auto-set sort to x-axis ASC if selecting a datetime column
if (value) {
const selectedCol = columns.find((c) => c.name === value);
if (selectedCol && isDateTimeType(selectedCol.type)) {
updates.sortByColumn = value;
updates.sortDirection = "asc";
}
}
updateConfig(updates);
}}
variant="tertiary/small"
placeholder="Select column"
items={xAxisOptions}
dropdownIcon
className="min-w-[140px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Y-Axis / Series */}
<ConfigField label={config.yAxisColumns.length > 1 ? "Series" : "Y-Axis"}>
{yAxisOptions.length === 0 ? (
<span className="text-xs text-text-dimmed">No numeric columns</span>
) : (
<div className="flex flex-col gap-1.5">
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
(col, index) => (
<div key={index} className="flex items-center gap-1">
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
updateConfig({ yAxisColumns: newColumns });
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
updateConfig({ yAxisColumns: newColumns });
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
)
)}
{/* Add another series button - only show when we have at least one series and not grouped */}
{config.yAxisColumns.length > 0 &&
config.yAxisColumns.length < yAxisOptions.length &&
!config.groupByColumn && (
<button
type="button"
onClick={() => {
const availableColumns = yAxisOptions.filter(
(opt) => !config.yAxisColumns.includes(opt.value)
);
if (availableColumns.length > 0) {
updateConfig({
yAxisColumns: [...config.yAxisColumns, availableColumns[0].value],
});
}
}}
className="flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
>
<Plus className="h-3 w-3" />
Add series
</button>
)}
{config.groupByColumn && config.yAxisColumns.length === 1 && (
<span className="text-xxs text-text-dimmed">
Remove group by to add multiple series
</span>
)}
</div>
)}
</ConfigField>
{/* Aggregation */}
<ConfigField label="Aggregation">
<Select
value={config.aggregation}
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
variant="tertiary/small"
items={aggregationOptions}
dropdownIcon
className="min-w-[100px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Group By - disabled when multiple series are selected */}
<ConfigField label="Group by">
{config.yAxisColumns.length > 1 ? (
<span className="text-xs text-text-dimmed">
Not available with multiple series
</span>
) : (
<Select
value={config.groupByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ groupByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={groupByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
)}
</ConfigField>
{/* Stacked toggle (when grouped or multiple series) */}
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
<Switch
variant="medium"
checked={config.stacked}
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
/>
</ConfigField>
)}
{/* Order By */}
<ConfigField label="Order by">
<Select
value={config.sortByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ sortByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={sortByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="Sort direction">
<SortDirectionToggle
direction={config.sortDirection}
onChange={(direction) => updateConfig({ sortDirection: direction })}
/>
</ConfigField>
)}
</div>
</div>
);
}
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
{label && <span className="text-xs text-text-dimmed">{label}</span>}
{children}
</div>
);
}
function SortDirectionToggle({
direction,
onChange,
}: {
direction: SortDirection;
onChange: (direction: SortDirection) => void;
}) {
return (
<div className="flex gap-1">
<button
type="button"
onClick={() => onChange("asc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "asc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Ascending"
>
Asc
</button>
<button
type="button"
onClick={() => onChange("desc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "desc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Descending"
>
Desc
</button>
</div>
);
}
function TypeBadge({ type }: { type: string }) {
// Simplify type for display
let displayType = type;
if (type.startsWith("Nullable(")) {
displayType = type.slice(9, -1) + "?";
}
if (type.startsWith("LowCardinality(")) {
displayType = type.slice(15, -1);
}
// Shorten long type names
if (displayType.length > 12) {
displayType = displayType.slice(0, 10) + "…";
}
return (
<span className="rounded bg-charcoal-750 px-1 py-0.5 font-mono text-xxs text-text-dimmed">
{displayType}
</span>
);
}
+22 -4
View File
@@ -5,6 +5,7 @@ import { Highlight, Prism } from "prism-react-renderer";
import { forwardRef, ReactNode, useCallback, useEffect, useState } from "react";
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
import { cn } from "~/utils/cn";
import { highlightSearchText } from "~/utils/logUtils";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
@@ -20,6 +21,8 @@ async function setup() {
await import("prismjs/components/prism-json");
//@ts-ignore
await import("prismjs/components/prism-typescript");
//@ts-ignore
await import("prismjs/components/prism-sql.js");
}
setup();
@@ -62,6 +65,9 @@ type CodeBlockProps = {
/** Whether to show the open in modal button */
showOpenInModal?: boolean;
/** Search term to highlight in the code */
searchTerm?: string;
};
const dimAmount = 0.5;
@@ -200,6 +206,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
showChrome = false,
fileName,
rowTitle,
searchTerm,
...props
}: CodeBlockProps,
ref
@@ -236,7 +243,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
[code]
);
code = code.trim();
code = code?.trim() ?? "";
const lineCount = code.split("\n").length;
const maxLineWidth = lineCount.toString().length;
let maxHeight: string | undefined = undefined;
@@ -338,6 +345,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
className="px-2 py-3"
preClassName="text-xs"
isWrapped={isWrapped}
searchTerm={searchTerm}
/>
) : (
<div
@@ -358,7 +366,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
)}
dir="ltr"
>
{code}
{highlightSearchText(code, searchTerm)}
</pre>
</div>
)}
@@ -400,7 +408,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
{code}
{highlightSearchText(code, searchTerm)}
</pre>
</div>
)}
@@ -449,6 +457,7 @@ type HighlightCodeProps = {
className?: string;
preClassName?: string;
isWrapped: boolean;
searchTerm?: string;
};
function HighlightCode({
@@ -461,6 +470,7 @@ function HighlightCode({
className,
preClassName,
isWrapped,
searchTerm,
}: HighlightCodeProps) {
const [isLoaded, setIsLoaded] = useState(false);
@@ -470,6 +480,8 @@ function HighlightCode({
import("prismjs/components/prism-json"),
//@ts-ignore
import("prismjs/components/prism-typescript"),
//@ts-ignore
import("prismjs/components/prism-sql.js"),
]).then(() => setIsLoaded(true));
}, []);
@@ -552,6 +564,10 @@ function HighlightCode({
<div className="flex-1">
{line.map((token, key) => {
const tokenProps = getTokenProps({ token, key });
// Highlight search term matches in token
const content = highlightSearchText(token.content, searchTerm);
return (
<span
key={key}
@@ -560,7 +576,9 @@ function HighlightCode({
color: tokenProps?.style?.color as string,
...tokenProps.style,
}}
/>
>
{content}
</span>
);
})}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
import { sql, StandardSQL } from "@codemirror/lang-sql";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import type { ViewUpdate } from "@codemirror/view";
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
import {
type ReactCodeMirrorProps,
type UseCodeMirror,
useCodeMirror,
} from "@uiw/react-codemirror";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { getEditorSetup } from "./codeMirrorSetup";
import { darkTheme } from "./codeMirrorTheme";
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
import { createTSQLLinter } from "./tsql/tsqlLinter";
import type { TableSchema } from "@internal/tsql";
import { format as formatSQL } from "sql-formatter";
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
/** Initial value for the editor */
defaultValue?: string;
/** Whether the editor is read-only */
readOnly?: boolean;
/** Called when the editor content changes */
onChange?: (value: string) => void;
/** Called when the editor state updates */
onUpdate?: (update: ViewUpdate) => void;
/** Called when the editor loses focus */
onBlur?: (code: string) => void;
/** Schema for table/column autocompletion */
schema?: TableSchema[];
/** Show copy button */
showCopyButton?: boolean;
/** Show clear button */
showClearButton?: boolean;
/** Show format button */
showFormatButton?: boolean;
/** Enable linting (syntax checking) */
linterEnabled?: boolean;
/** Placeholder text when empty */
placeholder?: string;
/** Additional actions to show in the toolbar */
additionalActions?: React.ReactNode;
/** Minimum height of the editor */
minHeight?: string;
}
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
const defaultProps: TSQLEditorDefaultProps = {
readOnly: false,
basicSetup: false,
linterEnabled: true,
showCopyButton: true,
showClearButton: false,
showFormatButton: true,
schema: [],
};
export function TSQLEditor(opts: TSQLEditorProps) {
const {
defaultValue = "",
readOnly = false,
onChange,
onUpdate,
onBlur,
basicSetup = false,
autoFocus,
showCopyButton = true,
showClearButton = false,
showFormatButton = true,
linterEnabled = true,
schema = [],
placeholder = "",
additionalActions,
minHeight = undefined,
} = {
...defaultProps,
...opts,
};
// Create extensions - memoize to avoid recreating on every render
const extensions = useMemo(() => {
const exts = getEditorSetup();
// Add SQL language support with StandardSQL dialect
// This provides syntax highlighting
exts.push(
sql({
dialect: StandardSQL,
upperCaseKeywords: true,
})
);
// Add custom TSQL completion
if (schema && schema.length > 0) {
exts.push(
autocompletion({
override: [createTSQLCompletion(schema)],
activateOnTyping: true,
maxRenderedOptions: 50,
})
);
// Trigger autocomplete when ' is typed in value context
// CodeMirror's activateOnTyping only triggers on alphanumeric characters,
// so we manually trigger for quotes after comparison operators
exts.push(
EditorView.domEventHandlers({
keyup: (event, view) => {
// Trigger on quote key (both ' and shift+' on some keyboards)
if (event.key === "'" || event.key === '"' || event.code === "Quote") {
setTimeout(() => {
startCompletion(view);
}, 50);
}
return false;
},
})
);
}
// Add TSQL linter
if (linterEnabled) {
exts.push(lintGutter());
exts.push(
linter(createTSQLLinter({ schema }), {
delay: 300, // Debounce linting for better performance
})
);
}
return exts;
}, [schema, linterEnabled]);
const editor = useRef<HTMLDivElement>(null);
const settings: Omit<UseCodeMirror, "onBlur"> = {
...opts,
container: editor.current,
extensions,
editable: !readOnly,
contentEditable: !readOnly,
value: defaultValue,
autoFocus,
theme: darkTheme(),
indentWithTab: false,
basicSetup,
onChange,
onUpdate,
placeholder,
};
const { setContainer, view } = useCodeMirror(settings);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (editor.current) {
setContainer(editor.current);
}
}, [setContainer]);
// Update editor when defaultValue changes
useEffect(() => {
if (view !== undefined) {
if (view.state.doc.toString() === defaultValue) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
});
}
}, [defaultValue, view]);
const clear = () => {
if (view === undefined) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: undefined },
});
onChange?.("");
};
const copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
}, [view]);
const format = useCallback(() => {
if (view === undefined) return;
const currentContent = view.state.doc.toString();
if (!currentContent.trim()) return;
try {
const formatted = autoFormatSQL(currentContent);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: formatted },
});
onChange?.(formatted);
} catch {
// If formatting fails (e.g., invalid SQL), silently ignore
}
}, [view, onChange]);
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
return (
<div
className={cn("relative flex h-full flex-col", opts.className)}
style={minHeight ? { minHeight } : undefined}
>
<div
className={cn(
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={editor}
onBlur={() => {
if (!onBlur) return;
if (!view) return;
onBlur(view.state.doc.toString());
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
type="button"
variant="minimal/small"
className="flex-none"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
format();
}}
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
>
Format
</Button>
)}
{showClearButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={TrashIcon}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clear();
}}
>
Clear
</Button>
)}
{showCopyButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
trailingIconClassName={
copied ? "text-green-500 group-hover:text-green-500" : undefined
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copy();
}}
>
Copy
</Button>
)}
</div>
)}
</div>
);
}
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
// TSQL CodeMirror support
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
export { createTSQLCompletion } from "./tsqlCompletion";
export { createTSQLLinter, isValidTSQLQuery, getTSQLError, type TSQLLinterConfig } from "./tsqlLinter";
@@ -0,0 +1,480 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import {
type TableSchema,
type ColumnSchema,
TSQL_CLICKHOUSE_FUNCTIONS,
TSQL_AGGREGATIONS,
} from "@internal/tsql";
/**
* SQL keywords for autocomplete
*/
const SQL_KEYWORDS = [
"SELECT",
"FROM",
"WHERE",
"AND",
"OR",
"NOT",
"IN",
"LIKE",
"ILIKE",
"BETWEEN",
"IS",
"NULL",
"TRUE",
"FALSE",
"AS",
"ORDER",
"BY",
"ASC",
"DESC",
"LIMIT",
"OFFSET",
"GROUP",
"HAVING",
"DISTINCT",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"FULL",
"CROSS",
"ON",
"UNION",
"INTERSECT",
"EXCEPT",
"ALL",
"WITH",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"OVER",
"PARTITION",
"ROWS",
"RANGE",
"UNBOUNDED",
"PRECEDING",
"FOLLOWING",
"CURRENT",
"ROW",
"NULLS",
"FIRST",
"LAST",
];
/**
* Create keyword completions from the SQL keywords list
*/
function createKeywordCompletions(): Completion[] {
return SQL_KEYWORDS.map((keyword) => ({
label: keyword,
type: "keyword",
boost: -1, // Keywords should have lower priority than schema items
}));
}
/**
* Create function completions from TSQL function definitions
*/
function createFunctionCompletions(): Completion[] {
const functions: Completion[] = [];
// Add regular functions
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
// Skip internal functions starting with _
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0
? "()"
: meta.minArgs === meta.maxArgs
? `(${meta.minArgs} args)`
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: argsHint,
apply: `${name}()`,
});
}
// Add aggregate functions with slightly higher boost
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0
? "()"
: meta.minArgs === meta.maxArgs
? `(${meta.minArgs} args)`
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: `aggregate ${argsHint}`,
apply: `${name}()`,
boost: 0.5,
});
}
return functions;
}
/**
* Create table completions from schema
*/
function createTableCompletions(schema: TableSchema[]): Completion[] {
return schema.map((table) => ({
label: table.name,
type: "class", // Using "class" type for tables gives them a nice icon
detail: table.description || "table",
boost: 1, // Tables should have higher priority
}));
}
/**
* Create column completions for a specific table
*/
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
const columns: Completion[] = [];
for (const [name, column] of Object.entries(table.columns)) {
columns.push({
label: prefix ? `${prefix}.${name}` : name,
type: "property", // Using "property" type for columns
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
boost: 2, // Columns should have highest priority
});
}
return columns;
}
/**
* Extract table names/aliases from the current query context
* This is a simplified parser that looks for FROM and JOIN clauses
*/
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
const tableMap = new Map<string, TableSchema>();
const tableNames = schema.map((t) => t.name);
// Simple regex to find table references in FROM and JOIN clauses
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
let match;
while ((match = tablePattern.exec(doc)) !== null) {
const tableName = match[1];
const alias = match[2] || tableName;
// Find the table schema if it exists
const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase());
if (tableSchema) {
tableMap.set(alias.toLowerCase(), tableSchema);
}
}
return tableMap;
}
/**
* Determine what context we're in based on cursor position
*/
type CompletionContextType =
| "table" // After FROM or JOIN
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
| "alias" // After table_name.
| "value" // After comparison operator (=, !=, IN, etc.)
| "general"; // Anywhere else
/**
* Result of context detection
*/
interface ContextResult {
type: CompletionContextType;
tablePrefix?: string;
/** Column being compared (for value context) */
columnName?: string;
/** Table alias for the column (for value context) */
columnTableAlias?: string;
}
/**
* Extract column name from text before a comparison operator
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
*/
function extractColumnBeforeOperator(
textBefore: string
): { columnName: string; tableAlias?: string } | null {
// Match patterns like: column =, column !=, column IN, table.column =, etc.
// We need to capture the column (and optional table prefix) before the operator
// Also match when user is typing a partial string value like: column = 'val
const patterns = [
// column = or column != or column <> (with optional whitespace and optional partial string value)
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
/(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
// column IN ( or column NOT IN ( (with optional partial string value)
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
// After a comma in IN clause (with optional partial string value)
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
];
for (const pattern of patterns) {
const match = textBefore.match(pattern);
if (match) {
if (match.length === 3) {
// table.column pattern
return { tableAlias: match[1], columnName: match[2] };
} else {
// just column pattern
return { columnName: match[1] };
}
}
}
return null;
}
function determineContext(doc: string, pos: number): ContextResult {
// Get text before cursor
const textBefore = doc.slice(0, pos);
// Check if we're in a value context (after comparison operator)
// This should be checked before other contexts
const columnInfo = extractColumnBeforeOperator(textBefore);
if (columnInfo) {
return {
type: "value",
columnName: columnInfo.columnName,
columnTableAlias: columnInfo.tableAlias,
};
}
// Check if we're completing after a dot (table.column)
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
if (dotMatch) {
return { type: "alias", tablePrefix: dotMatch[1] };
}
// Find the LAST significant keyword before cursor
// We match all keywords and take the last one
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
let lastMatch: RegExpExecArray | null = null;
let match: RegExpExecArray | null;
while ((match = keywordPattern.exec(textBefore)) !== null) {
lastMatch = match;
}
if (lastMatch) {
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
if (keyword === "FROM" || keyword === "JOIN") {
return { type: "table" };
}
if (
keyword === "SELECT" ||
keyword === "WHERE" ||
keyword === "AND" ||
keyword === "OR" ||
keyword === "ORDER BY" ||
keyword === "GROUP BY" ||
keyword === "HAVING" ||
keyword === "ON"
) {
return { type: "column" };
}
}
return { type: "general" };
}
/**
* Find a column schema by name in the tables map
*/
function findColumnSchema(
columnName: string,
tableAlias: string | undefined,
tables: Map<string, TableSchema>
): ColumnSchema | null {
if (tableAlias) {
// Look in specific table
const tableSchema = tables.get(tableAlias.toLowerCase());
if (tableSchema) {
return tableSchema.columns[columnName] || null;
}
} else {
// Look in all tables
for (const tableSchema of tables.values()) {
const col = tableSchema.columns[columnName];
if (col) {
return col;
}
}
}
return null;
}
/**
* Create completions for enum values from allowedValues
*/
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
return [];
}
return columnSchema.allowedValues.map((value) => ({
label: `'${value}'`,
type: "enum",
detail: columnSchema.description || "allowed value",
boost: 3, // Highest priority for enum values in value context
}));
}
/**
* Create a TSQL-aware autocompletion source
*
* @param schema - Array of table schemas to use for completions
* @returns A CodeMirror completion source function
*/
export function createTSQLCompletion(
schema: TableSchema[]
): (context: CompletionContext) => CompletionResult | null {
// Pre-compute static completions
const keywordCompletions = createKeywordCompletions();
const functionCompletions = createFunctionCompletions();
const tableCompletions = createTableCompletions(schema);
return (context: CompletionContext): CompletionResult | null => {
// Get the word being typed - include single quotes for value completion
const word = context.matchBefore(/[\w.']+/);
// Don't show completions if no word is being typed and not explicitly triggered
if (!word && !context.explicit) {
return null;
}
const from = word ? word.from : context.pos;
const doc = context.state.doc.toString();
const queryContext = determineContext(doc, context.pos);
let options: Completion[] = [];
// Track if we need to extend replacement range (e.g., to consume auto-paired closing quote)
let to: number | undefined = undefined;
switch (queryContext.type) {
case "table":
// After FROM or JOIN, show only tables
options = tableCompletions;
break;
case "alias":
// After table., show columns for that table
if (queryContext.tablePrefix) {
const tables = extractTablesFromQuery(doc, schema);
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
if (tableSchema) {
options = createColumnCompletions(tableSchema);
}
}
break;
case "value":
// After comparison operator, show enum values if available
if (queryContext.columnName) {
const tables = extractTablesFromQuery(doc, schema);
const columnSchema = findColumnSchema(
queryContext.columnName,
queryContext.columnTableAlias,
tables
);
if (columnSchema) {
options = createEnumValueCompletions(columnSchema);
// Check if there's a closing quote right after cursor (from auto-pairing)
// If so, extend replacement range to include it to avoid 'Completed''
const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1);
if (charAfterCursor === "'") {
to = context.pos + 1;
}
}
}
break;
case "column":
// After SELECT, WHERE, etc., show columns, functions, and some keywords
{
const tables = extractTablesFromQuery(doc, schema);
// Add columns from all tables in the query
tables.forEach((tableSchema, alias) => {
// If multiple tables, prefix with alias
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
// Also add functions and relevant keywords
options.push(...functionCompletions);
options.push(
...keywordCompletions.filter((k) =>
[
"AND",
"OR",
"NOT",
"IN",
"LIKE",
"ILIKE",
"BETWEEN",
"IS",
"NULL",
"AS",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
].includes(k.label as string)
)
);
}
break;
case "general":
default:
// Show everything
options = [...tableCompletions, ...functionCompletions, ...keywordCompletions];
// Also add columns from tables in query
{
const tables = extractTablesFromQuery(doc, schema);
tables.forEach((tableSchema, alias) => {
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
}
break;
}
const result: CompletionResult = {
from,
options,
validFor: /^[\w.']*$/,
};
// Only set 'to' if we need to extend the replacement range
if (to !== undefined) {
result.to = to;
}
return result;
};
}
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
)
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle missing FROM clause", () => {
const error = getTSQLError("SELECT * WHERE id = 1");
expect(error).not.toBeNull();
});
});
});
@@ -0,0 +1,213 @@
import type { EditorView } from "@codemirror/view";
import type { Diagnostic } from "@codemirror/lint";
import type { TableSchema } from "@internal/tsql";
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
/**
* Configuration for the TSQL linter
*/
export interface TSQLLinterConfig {
/** Optional schema for validating table/column names */
schema?: TableSchema[];
/** Delay in milliseconds before running the linter (debouncing) */
delay?: number;
}
/**
* Extract line and column from a TSQL error message
* Error format: "Syntax error at line X:Y: message"
*/
function parseErrorPosition(message: string): { line: number; column: number } | null {
const match = message.match(/at line (\d+):(\d+)/);
if (match) {
return {
line: parseInt(match[1], 10),
column: parseInt(match[2], 10),
};
}
return null;
}
/**
* Convert line/column to a document position
*/
function positionToOffset(
doc: string,
line: number,
column: number
): number {
const lines = doc.split("\n");
// line is 1-indexed
let offset = 0;
for (let i = 0; i < line - 1 && i < lines.length; i++) {
offset += lines[i].length + 1; // +1 for newline
}
return offset + column;
}
/**
* Find the end of a word/token at the given position
*/
function findTokenEnd(doc: string, start: number): number {
let end = start;
// Scan forward until we hit whitespace or end of string
while (end < doc.length && /\S/.test(doc[end])) {
end++;
}
// If we didn't move, include at least one character
if (end === start) {
end = Math.min(start + 1, doc.length);
}
return end;
}
/**
* Create a TSQL linter function for CodeMirror
*
* This linter uses the TSQL ANTLR parser to detect syntax errors
* and optionally validates against a schema.
*
* @param config - Linter configuration
* @returns A linter function for use with CodeMirror's linter extension
*/
export function createTSQLLinter(
config: TSQLLinterConfig = {}
): (view: EditorView) => Diagnostic[] {
const { schema = [] } = config;
return (view: EditorView): Diagnostic[] => {
const content = view.state.doc.toString().trim();
// Return no errors for empty content
if (!content) {
return [];
}
const diagnostics: Diagnostic[] = [];
try {
// Try to parse the query
const ast = parseTSQLSelect(content);
// If parsing succeeds and we have a schema, run schema validation
if (schema.length > 0) {
const validationResult = validateQuery(ast, schema);
for (const issue of validationResult.issues) {
// Map validation severity to CodeMirror diagnostic severity
const severity: "error" | "warning" | "info" =
issue.severity === "error"
? "error"
: issue.severity === "warning"
? "warning"
: "info";
diagnostics.push({
from: 0,
to: content.length,
severity,
message: issue.message,
source: "tsql",
});
}
}
} catch (error) {
if (error instanceof SyntaxError) {
const position = parseErrorPosition(error.message);
let from: number;
let to: number;
if (position) {
from = positionToOffset(content, position.line, position.column);
to = findTokenEnd(content, from);
} else {
// If we can't parse the position, highlight the whole query
from = 0;
to = content.length;
}
// Clean up the error message
let message = error.message;
// Remove the "Syntax error at line X:Y: " prefix if present
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
diagnostics.push({
from,
to,
severity: "error",
message: message,
source: "tsql",
});
} else if (error instanceof QueryError) {
// Schema validation errors don't have position info,
// so highlight the whole query
diagnostics.push({
from: 0,
to: content.length,
severity: "warning",
message: error.message,
source: "tsql",
});
} else if (error instanceof Error) {
// Unknown error
diagnostics.push({
from: 0,
to: content.length,
severity: "error",
message: error.message,
source: "tsql",
});
}
}
return diagnostics;
};
}
/**
* Check if a TSQL query is valid
*
* @param query - The query to validate
* @returns true if the query is valid, false otherwise
*/
export function isValidTSQLQuery(query: string): boolean {
if (!query.trim()) {
return true; // Empty queries are considered valid
}
try {
parseTSQLSelect(query);
return true;
} catch {
return false;
}
}
/**
* Get error message for a TSQL query, if any
*
* @param query - The query to validate
* @returns Error message if invalid, null if valid
*/
export function getTSQLError(query: string): string | null {
if (!query.trim()) {
return null;
}
try {
parseTSQLSelect(query);
return null;
} catch (error) {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}
}
@@ -51,10 +51,14 @@ export function EnvironmentCombo({
environment,
className,
iconClassName,
tooltipSideOffset,
tooltipSide,
}: {
environment: Environment;
className?: string;
iconClassName?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
}) {
return (
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
@@ -62,7 +66,11 @@ export function EnvironmentCombo({
environment={environment}
className={cn("size-4.5 shrink-0", iconClassName)}
/>
<EnvironmentLabel environment={environment} />
<EnvironmentLabel
environment={environment}
tooltipSideOffset={tooltipSideOffset}
tooltipSide={tooltipSide}
/>
</span>
);
}
@@ -70,9 +78,15 @@ export function EnvironmentCombo({
export function EnvironmentLabel({
environment,
className,
tooltipSideOffset = 34,
tooltipSide = "right",
disableTooltip = false,
}: {
environment: Environment;
className?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
disableTooltip?: boolean;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -105,7 +119,7 @@ export function EnvironmentLabel({
</span>
);
if (isTruncated) {
if (isTruncated && !disableTooltip) {
return (
<SimpleTooltip
asChild
@@ -115,9 +129,10 @@ export function EnvironmentLabel({
{text}
</span>
}
side="right"
side={tooltipSide}
variant="dark"
sideOffset={34}
sideOffset={tooltipSideOffset}
disableHoverableContent
/>
);
}
@@ -125,6 +140,10 @@ export function EnvironmentLabel({
return content;
}
export function EnvironmentSlug({ environment }: { environment: Environment & { slug: string } }) {
return <span className={environmentTextClassName(environment)}>{environment.slug}</span>;
}
export function environmentTitle(environment: Environment, username?: string) {
if (environment.branchName) {
return environment.branchName;
@@ -94,7 +94,11 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
Regenerate
</Button>
}
cancelButton={<Button variant={"tertiary/medium"}>Cancel</Button>}
cancelButton={
<Button variant={"tertiary/medium"} type="button" onClick={closeModal}>
Cancel
</Button>
}
/>
</Fieldset>
</fetcher.Form>
@@ -0,0 +1,483 @@
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import {
type MachinePresetName,
formatDurationMilliseconds,
} from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import * as Property from "~/components/primitives/PropertyTable";
import { TextLink } from "~/components/primitives/TextLink";
import { CopyableText } from "~/components/primitives/CopyableText";
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { RunTag } from "~/components/runs/v3/RunTag";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { TaskRunStatus } from "@trigger.dev/database";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
type RunContextData = {
run: RunContext | null;
};
type LogDetailViewProps = {
logId: string;
// If we have the log entry from the list, we can display it immediately
initialLog?: LogEntry;
onClose: () => void;
searchTerm?: string;
};
type TabType = "details" | "run";
type LogAttributes = Record<string, unknown> & {
error?: {
message?: string;
};
};
function formatStringJSON(str: string): string {
return str
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
}
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof logDetailLoader>();
const [activeTab, setActiveTab] = useState<TabType>("details");
const [error, setError] = useState<string | null>(null);
// Fetch full log details when logId changes
useEffect(() => {
if (!logId) return;
setError(null);
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, logId]);
// Handle fetch errors
useEffect(() => {
if (fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data) {
setError(fetcher.data.error as string);
} else if (fetcher.state === "idle" && fetcher.data === null && !initialLog) {
setError("Failed to load log details");
} else {
setError(null);
}
}, [fetcher.data, initialLog, fetcher.state]);
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: log?.runId ?? "" },
{ spanId: log?.spanId ?? "" }
);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.contentEditable === "true"
)) {
return;
}
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, log, runPath, isLoading]);
if (isLoading && !log) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
if (!log) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
<Header2>Log Details</Header2>
<Button variant="minimal/small" onClick={onClose}>
<XMarkIcon className="size-5" />
</Button>
</div>
<div className="flex flex-1 items-center justify-center">
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
<XMarkIcon className="size-5" />
</Button>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
<TabContainer>
<TabButton
isActive={activeTab === "details"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("details")}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={activeTab === "run"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("run")}
shortcut={{ key: "r" }}
>
Run
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
View full run
</Button>
</Link>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "details" && (
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
)}
{activeTab === "run" && (
<RunTab log={log} runPath={runPath} />
)}
</div>
</div>
);
}
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
const logWithExtras = log as LogEntry & {
attributes?: LogAttributes;
};
let beautifiedAttributes: string | null = null;
if (logWithExtras.attributes) {
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
}
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
// Determine message to show
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
return (
<>
{/* Time */}
<div className="mb-6">
<Header3 className="mb-2">Timestamp</Header3>
<div className="text-sm text-text-dimmed">
<DateTime date={log.startTime} />
</div>
</div>
{/* Message */}
<div className="mb-6">
<PacketDisplay
data={message}
dataType="application/json"
title="Message"
searchTerm={searchTerm}
/>
</div>
{/* Attributes - only available in full log detail */}
{showAttributes && beautifiedAttributes && (
<div className="mb-6">
<PacketDisplay
data={beautifiedAttributes}
dataType="application/json"
title="Attributes"
searchTerm={searchTerm}
/>
</div>
)}
</>
);
}
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<RunContextData>();
// Fetch run details when tab is active
useEffect(() => {
if (!log.runId) return;
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
const isLoading = fetcher.state === "loading";
const runData = fetcher.data?.run;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
);
}
if (!runData) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText
value={runData.taskIdentifier}
copyValue={runData.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
{runData.rootRun && (
<Property.Item>
<Property.Label>Root and parent run</Property.Label>
<Property.Value>
<CopyableText
value={runData.rootRun.taskIdentifier}
copyValue={runData.rootRun.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
)}
{runData.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<CopyableText
value={runData.batch.friendlyId}
copyValue={runData.batch.friendlyId}
asChild
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{runData.version ? (
environment.type === "DEVELOPMENT" ? (
<CopyableText value={runData.version} copyValue={runData.version} asChild />
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
runData.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText value={runData.version} copyValue={runData.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div>Name: {runData.queue}</div>
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : ""}</div>
</Property.Value>
</Property.Item>
{runData.tags && runData.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{runData.tags.map((tag: string) => (
<RunTag
key={tag}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
{runData.machinePreset ? (
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{runData.baseCostInCents > 0
? formatCurrencyAccurate(runData.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 || runData.baseCostInCents > 0
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{runData.usageDurationMs > 0
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
);
}
@@ -0,0 +1,135 @@
import * as Ariakit from "@ariakit/react";
import { IconListTree } from "@tabler/icons-react";
import { type ReactNode } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
{ level: "INFO", label: "Info", color: "text-blue-400" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "ERROR", label: "Error", color: "text-error" },
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
];
// In the future we might add other levels or change which are available
function getAvailableLevels(): typeof allLogLevels {
return allLogLevels;
}
function getLevelBadgeColor(level: LogLevel): string {
switch (level) {
case "ERROR":
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
default:
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
}
}
const shortcut = { key: "l" };
export function LogsLevelFilter() {
const { values } = useSearchParams();
const selectedLevels = values("levels");
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
if (hasLevels) {
return <AppliedLevelFilter/>;
}
return (
<LevelDropdown
trigger={
<SelectTrigger
icon={<IconListTree className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by level"
>
Level
</SelectTrigger>
}
/>
);
}
function LevelDropdown({
trigger,
}: {
trigger: ReactNode;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
replace({ levels: values, cursor: undefined, direction: undefined });
};
const availableLevels = getAvailableLevels();
return (
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
{availableLevels.map((item, index) => (
<SelectItem
key={item.level}
value={item.level}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
getLevelBadgeColor(item.level)
)}
>
{item.level}
</span>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedLevelFilter() {
const { values, del } = useSearchParams();
const levels = values("levels");
if (levels.length === 0 || levels.every((v) => v === "")) {
return null;
}
return (
<LevelDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Level"
icon={<IconListTree className="size-4" />}
value={appliedSummary(levels)}
onRemove={() => del(["levels", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
/>
);
}
@@ -0,0 +1,161 @@
import * as Ariakit from "@ariakit/react";
import { FingerPrintIcon } from "@heroicons/react/20/solid";
import { useCallback, useState } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button } from "~/components/primitives/Buttons";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import {
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "i" };
export function LogsRunIdFilter() {
const { value } = useSearchParams();
const runIdValue = value("runId");
if (runIdValue) {
return <AppliedRunIdFilter />;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<RunIdDropdown
trigger={
<SelectTrigger
icon={<FingerPrintIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by run ID"
>
Run ID
</SelectTrigger>
}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function RunIdDropdown({
trigger,
clearSearchValue,
onClose,
}: {
trigger: React.ReactNode;
clearSearchValue: () => void;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const runIdValue = value("runId");
const [runId, setRunId] = useState(runIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
runId: runId === "" ? undefined : runId?.toString(),
});
setOpen(false);
}, [runId, replace, clearSearchValue]);
let error: string | undefined = undefined;
if (runId) {
if (!runId.startsWith("run_")) {
error = "Run IDs start with 'run_'";
} else if (runId.length !== 25 && runId.length !== 29) {
error = "Run IDs are 25 or 29 characters long";
}
}
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Run ID</Label>
<Input
placeholder="run_"
value={runId ?? ""}
onChange={(e) => setRunId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !runId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedRunIdFilter() {
const { value, del } = useSearchParams();
const runId = value("runId");
if (!runId) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<RunIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Run ID"
icon={<FingerPrintIcon className="size-4" />}
value={runId}
onRemove={() => del(["runId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,107 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { cn } from "~/utils/cn";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
export function LogsSearchInput() {
const location = useOptimisticLocation();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
// Get initial search value from URL
const searchParams = new URLSearchParams(location.search);
const initialSearch = searchParams.get("search") ?? "";
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
useEffect(() => {
const params = new URLSearchParams(location.search);
const urlSearch = params.get("search") ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
const handleSubmit = useCallback(() => {
const params = new URLSearchParams(location.search);
if (text.trim()) {
params.set("search", text.trim());
} else {
params.delete("search");
}
// Reset cursor when searching
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [text, location.pathname, location.search, navigate]);
const handleClear = useCallback(() => {
setText("");
const params = new URLSearchParams(location.search);
params.delete("search");
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate]);
return (
<div className="flex items-center gap-1">
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="relative h-6 min-w-52"
>
<Input
type="text"
ref={inputRef}
variant="secondary-small"
placeholder="Search logs…"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
if (e.key === "Escape") {
e.currentTarget.blur();
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
) : undefined
}
/>
</motion.div>
{text.length > 0 && (
<button
type="button"
onClick={handleClear}
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-4" />
</button>
)}
</div>
);
}
@@ -0,0 +1,251 @@
import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
type TableVariant,
} from "../primitives/Table";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { Link } from "@remix-run/react";
type LogsTableProps = {
logs: LogEntry[];
searchTerm?: string;
isLoading?: boolean;
isLoadingMore?: boolean;
hasMore?: boolean;
onLoadMore?: () => void;
variant?: TableVariant;
selectedLogId?: string;
onLogSelect?: (logId: string) => void;
};
// Inner shadow for level highlighting (better scroll performance than border-l)
function getLevelBoxShadow(level: LogEntry["level"]): string {
switch (level) {
case "ERROR":
return "inset 2px 0 0 0 rgb(239, 68, 68)";
case "WARN":
return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "DEBUG":
default:
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
isLoading = false,
isLoadingMore = false,
hasMore = false,
onLoadMore,
selectedLogId,
onLogSelect,
}: LogsTableProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const loadMoreRef = useRef<HTMLDivElement>(null);
const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false);
// Show load more spinner only after 0.2 seconds of loading time
useEffect(() => {
if (!isLoadingMore) {
setShowLoadMoreSpinner(false);
return;
}
const timer = setTimeout(() => {
setShowLoadMoreSpinner(true);
}, 200);
return () => clearTimeout(timer);
}, [isLoadingMore]);
// Intersection observer for infinite scroll
useEffect(() => {
if (!hasMore || isLoadingMore || !onLoadMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
onLoadMore();
}
},
{ threshold: 0.1 }
);
const currentRef = loadMoreRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [hasMore, isLoadingMore, onLoadMore]);
return (
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
<TableHeader className="sticky top-0 z-10">
<TableRow>
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
<TableHeaderCell
className="min-w-24 whitespace-nowrap"
tooltip={<LogLevelTooltipInfo />}
>
Level
</TableHeaderCell>
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<BlankState isLoading={isLoading} onRefresh={() => window.location.reload()} />
) : (
logs.map((log) => {
const isSelected = selectedLogId === log.id;
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: log.runId },
{ spanId: log.spanId }
);
const handleRowClick = () => onLogSelect?.(log.id);
return (
<TableRow
key={log.id}
className={cn(
"cursor-pointer transition-colors",
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
)}
isSelected={isSelected}
>
<TableCell
className="whitespace-nowrap tabular-nums"
onClick={handleRowClick}
hasAction
style={{
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTime date={log.startTime} />
</TableCell>
<TableCell className="min-w-24">
<TruncatedCopyableValue value={log.runId} />
</TableCell>
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
<span className="font-mono text-xs">{log.taskIdentifier}</span>
</TableCell>
<TableCell onClick={handleRowClick} hasAction>
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
</TableCell>
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
<span className="block truncate font-mono text-xs" title={log.message}>
{highlightSearchText(log.message, searchTerm)}
</span>
</TableCell>
<TableCellMenu
className="pl-32"
hiddenButtons={
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
View run
</Button>
</Link>
}
/>
</TableRow>
);
})
)}
</TableBody>
</Table>
{/* Infinite scroll trigger */}
{hasMore && logs.length > 0 && (
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
<div
className={cn(
"flex items-center gap-2",
!showLoadMoreSpinner && "invisible"
)}
>
<Spinner /> <span className="text-text-dimmed">Loading more</span>
</div>
</div>
)}
{/* Show all logs message */}
{!hasMore && logs.length > 0 && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-2">
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
</div>
</div>
)}
</div>
);
}
function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?: () => void }) {
if (isLoading) return <TableBlankRow colSpan={6}></TableBlankRow>;
const handleRefresh = onRefresh ?? (() => window.location.reload());
return (
<TableBlankRow colSpan={6}>
<div className="flex flex-col items-center justify-center gap-6">
<Paragraph className="w-auto" variant="base/bright">
No logs match your filters. Try refreshing or modifying your filters.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={handleRefresh}
>
Refresh
</Button>
</div>
</div>
</TableBlankRow>
);
}
@@ -0,0 +1,144 @@
import type { TaskTriggerSource } from "@trigger.dev/database";
import type { ReactNode } from "react";
import { useMemo } from "react";
import * as Ariakit from "@ariakit/react";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
const shortcut = { key: "t" };
type TaskOption = {
slug: string;
triggerSource: TaskTriggerSource;
};
interface LogsTaskFilterProps {
possibleTasks: TaskOption[];
}
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedTasks = values("tasks");
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<SelectTrigger
icon={<TaskIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by task"
>
Tasks
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
icon={<TaskIcon className="size-4" />}
value={appliedSummary(
selectedTasks.map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleTasks,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleTasks: TaskOption[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ tasks: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleTasks.filter((item) => {
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleTasks]);
return (
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
{item.slug}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -8,10 +8,11 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
export function AccountSideMenu({ user }: { user: User }) {
return (
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
data-action="security"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
@@ -1,4 +1,5 @@
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
import { ButtonContent } from "../primitives/Buttons";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
PopoverTrigger,
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip } from "../primitives/Tooltip";
import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
project,
environment,
className,
isCollapsed = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
<PopoverArrowTrigger
isOpen={isMenuOpen}
overflowHidden
fullWidth
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
>
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
</PopoverArrowTrigger>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
<span
className={cn(
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={environmentFullTitle(environment)}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
@@ -8,9 +8,12 @@ import {
SignalIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
import { StepContentContainer } from "../StepContentContainer";
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { SimpleTooltip } from "../primitives/Tooltip";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { StepNumber } from "../primitives/StepNumber";
import { SideMenuItem } from "./SideMenuItem";
import { Badge } from "../primitives/Badge";
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
export function HelpAndFeedback({
disableShortcut = false,
isCollapsed = false,
}: {
disableShortcut?: boolean;
isCollapsed?: boolean;
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
action: (e) => {
e.preventDefault();
e.stopPropagation();
setHelpMenuOpen(true);
},
});
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger
isOpen={isHelpMenuOpen}
shortcut={{ key: "h", enabledOnInputElements: false }}
className="grow pr-2"
disabled={disableShortcut}
>
<div className="flex items-center gap-1.5">
<QuestionMarkCircleIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<motion.div
layout="position"
transition={{ duration: 0.2, ease: "easeInOut" }}
className={isCollapsed ? undefined : "flex-1"}
>
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
<span
className={cn(
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
)}
>
Help & Feedback
</span>
</span>
<ShortcutKey
className={cn(
"size-4 flex-none transition-all duration-150",
isCollapsed ? "hidden" : ""
)}
shortcut={{ key: "h" }}
variant="medium/bright"
/>
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8 w-full"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
button={
<Button
variant="small-menu-item"
className="pl-2"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
leadingIconClassName="text-blue-500 pr-1"
data-action="contact-us"
fullWidth
textAlignLeft
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
</div>
</Fragment>
</PopoverContent>
</Popover>
</Popover>
</motion.div>
);
}
@@ -25,6 +25,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Paragraph } from "../primitives/Paragraph";
import { Badge } from "../primitives/Badge";
import { useHasAdminAccess } from "~/hooks/useUser";
import { AskAI } from "../AskAI";
export type BuildInfo = {
appVersion: string | undefined;
@@ -144,8 +145,9 @@ export function OrganizationSettingsSideMenu({
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
+524 -109
View File
@@ -1,4 +1,5 @@
import {
AdjustmentsHorizontalIcon,
ArrowPathRoundedSquareIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
@@ -13,27 +14,36 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
UsersIcon,
TableCellsIcon,
UsersIcon
} from "@heroicons/react/20/solid";
import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { Avatar } from "~/components/primitives/Avatar";
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type User } from "~/models/user.server";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel } from "~/routes/resources.incidents";
@@ -42,12 +52,15 @@ import {
accountPath,
adminPath,
branchesPath,
concurrencyPath,
limitsPath,
logoutPath,
newOrganizationPath,
newProjectPath,
organizationPath,
organizationSettingsPath,
organizationTeamPath,
queryPath,
regionsPath,
v3ApiKeysPath,
v3BatchesPath,
@@ -56,6 +69,7 @@ import {
v3DeploymentsPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3LogsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
@@ -66,6 +80,7 @@ import {
v3UsagePath,
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
import { AlphaBadge } from "../AlphaBadge";
import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
@@ -75,23 +90,23 @@ import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverTrigger,
PopoverTrigger
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { V4Badge } from "../V4Badge";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine"
@@ -117,10 +132,99 @@ export function SideMenu({
}: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
);
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
if (user.isImpersonating) return;
// Merge with any pending changes
pendingPreferencesRef.current = {
...pendingPreferencesRef.current,
...data,
};
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the actual submission by 500ms
debounceTimeoutRef.current = setTimeout(() => {
const pending = pendingPreferencesRef.current;
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}, 500);
},
[user.isImpersonating, preferencesFetcher]
);
// Flush pending preferences on unmount to avoid losing the last toggle
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}
};
}, [preferencesFetcher, user.isImpersonating]);
const handleToggleCollapsed = () => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
action: handleToggleCollapsed,
});
useEffect(() => {
const handleScroll = () => {
@@ -139,210 +243,306 @@ export function SideMenu({
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
isCollapsed ? "w-[2.75rem]" : "w-56"
)}
>
<div
className={cn(
"flex items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider ? "border-grid-bright" : "border-transparent"
)}
>
<div className="min-w-0 flex-1">
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
<div
className={cn(
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<ImpersonationBanner />
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-4 px-1">
<div className="space-y-1">
<SideMenuHeader title={"Environment"} />
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
</div>
</div>
<div>
<div className="w-full">
<SideMenuItem
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIconExtraSmall}
activeIconColor="text-runs"
inactiveIconColor="text-runs"
to={v3RunsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-batches"
inactiveIconColor="text-batches"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-schedules"
inactiveIconColor="text-schedules"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={RectangleStackIcon}
activeIconColor="text-queues"
inactiveIconColor="text-queues"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-deployments"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
</div>
<SideMenuSection title="Waitpoints">
<SideMenuItem
name="Tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
badge={<V4Badge />}
/>
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
onCollapseToggle={handleManageSectionToggle}
>
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-bulkActions"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-apiKeys"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-environmentVariables"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-alerts"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-preview"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-green-500"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-projectSettings"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel />
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between">
<HelpAndAI />
</div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</div>
</motion.div>
</div>
</div>
</div>
);
@@ -353,11 +553,13 @@ function ProjectSelector({
organization,
organizations,
user,
isCollapsed = false,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
user: SideMenuUser;
isCollapsed?: boolean;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
@@ -377,29 +579,64 @@ function ProjectSelector({
return (
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
<PopoverArrowTrigger
isOpen={isOrgMenuOpen}
overflowHidden
className="h-8 w-full justify-between py-1 pl-1.5"
>
<span className="flex items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</PopoverArrowTrigger>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
isCollapsed ? "justify-center pr-0.5" : "w-full justify-between pr-1"
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<span
className={cn(
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={`${organization.title} / ${project.name ?? "Select a project"}`}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
<div className="flex items-center gap-2.5">
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
<Link
to={organizationSettingsPath(organization)}
className="group relative box-content size-10 overflow-clip rounded-sm bg-charcoal-800"
>
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
</div>
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
<PencilSquareIcon className="size-5 text-text-bright" />
</div>
</Link>
<div className="space-y-0.5">
<Paragraph variant="small/bright">{organization.title}</Paragraph>
<div className="flex items-baseline gap-2">
@@ -604,12 +841,190 @@ function SelectorDivider() {
);
}
function HelpAndAI() {
/** Helper component that fades out but preserves width (collapses to 0 width) */
function CollapsibleElement({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<>
<ShortcutsAutoOpen />
<HelpAndFeedback />
<AskAI />
</>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
className
)}
>
{children}
</div>
);
}
/** Helper component that fades out and collapses height completely */
function CollapsibleHeight({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid transition-all duration-200 ease-in-out",
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
className
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
);
}
function AnimatedChevron({
isHovering,
isCollapsed,
}: {
isHovering: boolean;
isCollapsed: boolean;
}) {
// When hovering and expanded: left chevron (pointing left to collapse)
// When hovering and collapsed: right chevron (pointing right to expand)
// When not hovering: straight vertical line
const getRotation = () => {
if (!isHovering) return { top: 0, bottom: 0 };
if (isCollapsed) {
// Right chevron
return { top: -17, bottom: 17 };
} else {
// Left chevron
return { top: 17, bottom: -17 };
}
};
const { top, bottom } = getRotation();
// Calculate horizontal offset to keep chevron centered when rotated
// Left chevron: translate left (-1.5px)
// Right chevron: translate right (+1.5px)
const getTranslateX = () => {
if (!isHovering) return 0;
return isCollapsed ? 1.5 : -1.5;
};
return (
<motion.svg
width="4"
height="30"
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
initial={false}
animate={{
x: getTranslateX(),
}}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Top segment */}
<motion.line
x1="2"
y1="1.5"
x2="2"
y2="15"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: top,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
{/* Bottom segment */}
<motion.line
x1="2"
y1="15"
x2="2"
y2="28.5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: bottom,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
</motion.svg>
);
}
function CollapseToggle({
isCollapsed,
onToggle,
}: {
isCollapsed: boolean;
onToggle: () => void;
}) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
{/* Vertical line to mask the side menu border */}
<div className={cn(
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
isHovering ? "bg-charcoal-750" : "bg-background-bright"
)} />
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
onClick={onToggle}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
isHovering
? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright"
: "border border-transparent bg-transparent"
)}
>
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
{isCollapsed ? "Expand" : "Collapse"}
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -1,9 +1,21 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
export function SideMenuHeader({
title,
children,
isCollapsed = false,
collapsedTitle,
}: {
title: string;
children?: React.ReactNode;
isCollapsed?: boolean;
/** When provided, this text stays visible when collapsed and the rest fades out */
collapsedTitle?: string;
}) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
// If collapsedTitle is provided and title starts with it, split the title
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
return (
<div className="group flex items-center justify-between pl-1.5">
<h2 className="text-xs">{title}</h2>
<motion.div
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
initial={false}
animate={{
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
)}
</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
</PopoverContent>
</Popover>
) : null}
</div>
</motion.div>
);
}
@@ -1,8 +1,10 @@
import { type AnchorHTMLAttributes, type ReactNode } from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { type RenderIcon } from "../primitives/Icon";
import { type RenderIcon, Icon } from "../primitives/Icon";
import { SimpleTooltip } from "../primitives/Tooltip";
export function SideMenuItem({
icon,
@@ -14,6 +16,7 @@ export function SideMenuItem({
to,
badge,
target,
isCollapsed = false,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -24,30 +27,67 @@ export function SideMenuItem({
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
TrailingIcon={trailingIcon}
trailingIconClassName={trailingIconClassName}
to={to}
target={target}
className={cn(
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
</div>
</LinkButton>
<SimpleTooltip
button={
<Link
to={to}
target={target}
className={cn(
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
isActive ? "bg-tertiary" : ""
)}
>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="truncate text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</motion.div>
</Link>
}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
);
}
@@ -7,6 +7,9 @@ type Props = {
initialCollapsed?: boolean;
onCollapseToggle?: (isCollapsed: boolean) => void;
children: React.ReactNode;
/** When true, hides the section header and shows only children */
isSideMenuCollapsed?: boolean;
itemSpacingClassName?: string;
};
/** A collapsible section for the side menu
@@ -17,6 +20,8 @@ export function SideMenuSection({
initialCollapsed = false,
onCollapseToggle,
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -27,22 +32,42 @@ export function SideMenuSection({
}, [isCollapsed, onCollapseToggle]);
return (
<div>
<div
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={handleToggle}
>
<h2 className="text-xs">{title}</h2>
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<ToggleArrowIcon className="size-2" />
<h2 className="text-xs whitespace-nowrap">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
initial={false}
animate={{
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
/>
</div>
<AnimatePresence initial={false}>
<motion.div
className="w-full"
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
exit="collapsed"
@@ -63,6 +88,7 @@ export function SideMenuSection({
style={{ overflow: "hidden" }}
>
<motion.div
className={`w-full ${itemSpacingClassName}`}
variants={{
expanded: {
translateY: 0,
@@ -1,16 +1,71 @@
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
export function AnimatedNumber({ value }: { value: number }) {
/**
* Determines the number of decimal places to display based on the value.
* - For integers or large numbers (>=100), no decimals
* - For numbers >= 10, 1 decimal place
* - For numbers >= 1, 2 decimal places
* - For smaller numbers, up to 4 decimal places
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const absValue = Math.abs(value);
if (absValue >= 100) return 0;
if (absValue >= 10) return 1;
if (absValue >= 1) return 2;
if (absValue >= 0.1) return 3;
return 4;
}
/**
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
* - Rounds to an integer
* - Clamps to the valid 0-20 range for toLocaleString options
*/
function sanitizeDecimals(decimals: number): number {
if (!Number.isFinite(decimals)) {
return 0;
}
return Math.min(20, Math.max(0, Math.round(decimals)));
}
export function AnimatedNumber({
value,
duration = 0.5,
decimalPlaces,
}: {
value: number;
duration?: number;
/** Number of decimal places to display. If not provided, auto-detects based on value. */
decimalPlaces?: number;
}) {
const motionValue = useMotionValue(value);
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
// Determine decimal places - use provided value or auto-detect, then sanitize
const safeDecimals = useMemo(() => {
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
return sanitizeDecimals(rawDecimals);
}, [decimalPlaces, value]);
const display = useTransform(motionValue, (current) => {
if (safeDecimals === 0) {
return Math.round(current).toLocaleString();
}
return current.toLocaleString(undefined, {
minimumFractionDigits: safeDecimals,
maximumFractionDigits: safeDecimals,
});
});
useEffect(() => {
animate(motionValue, value, {
duration: 0.5,
duration,
ease: "easeInOut",
});
}, [value]);
}, [value, duration]);
return <motion.span>{display}</motion.span>;
}
@@ -21,7 +21,7 @@ type Variant = keyof typeof variants;
type AppliedFilterProps = {
icon?: ReactNode;
label: ReactNode;
label?: ReactNode;
value: ReactNode;
removable?: boolean;
onRemove?: () => void;
@@ -48,12 +48,12 @@ export function AppliedFilter({
className
)}
>
<div className="flex items-start gap-0.5 leading-4">
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
<div className="-mt-[0.5px] flex items-center gap-1">
{icon}
<div className="text-text-bright">
{label && <div className="text-text-bright">
<span>{label}</span>:
</div>
</div>}
</div>
<div className="text-text-dimmed">
<div>{value}</div>
@@ -276,7 +276,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
@@ -298,19 +298,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled,
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled: disabled || !props.shortcut,
});
return (
<button
@@ -333,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
@@ -342,19 +340,20 @@ export const LinkButton = ({
onMouseLeave,
download,
disabled = false,
replace,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: disabled || !props.shortcut,
});
if (disabled) {
return (
@@ -374,7 +373,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -389,7 +388,8 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
replace={replace}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "w-fit")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -410,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -0,0 +1,126 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { format } from "date-fns";
import { DayPicker, useDayPicker } from "react-day-picker";
import { cn } from "~/utils/cn";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
const navButtonClass =
"size-7 rounded-[3px] bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 transition inline-flex items-center justify-center";
function CustomMonthCaption({ calendarMonth }: { calendarMonth: { date: Date } }) {
const { goToMonth, nextMonth, previousMonth } = useDayPicker();
return (
<div className="flex w-full items-center justify-between px-1">
<button
type="button"
className={navButtonClass}
disabled={!previousMonth}
onClick={() => previousMonth && goToMonth(previousMonth)}
aria-label="Go to previous month"
>
<ChevronLeftIcon className="size-4" />
</button>
<div className="flex items-center gap-2">
<select
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
value={calendarMonth.date.getMonth()}
onChange={(e) => {
const newDate = new Date(calendarMonth.date);
newDate.setMonth(parseInt(e.target.value));
goToMonth(newDate);
}}
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i} value={i}>
{format(new Date(2000, i), "MMM")}
</option>
))}
</select>
<select
className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none"
value={calendarMonth.date.getFullYear()}
onChange={(e) => {
const newDate = new Date(calendarMonth.date);
newDate.setFullYear(parseInt(e.target.value));
goToMonth(newDate);
}}
>
{Array.from({ length: 100 }, (_, i) => {
const year = new Date().getFullYear() - 50 + i;
return (
<option key={year} value={year}>
{year}
</option>
);
})}
</select>
</div>
<button
type="button"
className={navButtonClass}
disabled={!nextMonth}
onClick={() => nextMonth && goToMonth(nextMonth)}
aria-label="Go to next month"
>
<ChevronRightIcon className="size-4" />
</button>
</div>
);
}
export function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
weekStartsOn={1}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
month_caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "sr-only",
nav: "hidden",
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-charcoal-700 [&:has([aria-selected].day-outside)]:bg-charcoal-700/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md",
day_button: cn(
"size-8 p-0 font-normal text-text-bright rounded-md",
"hover:bg-charcoal-700 hover:text-text-bright",
"focus:bg-charcoal-700 focus:text-text-bright focus:outline-none",
"aria-selected:opacity-100"
),
range_start: "day-range-start rounded-l-md",
range_end: "day-range-end rounded-r-md",
selected:
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
today: "bg-charcoal-700 text-text-bright rounded-md",
outside:
"day-outside text-text-dimmed opacity-50 aria-selected:bg-charcoal-700/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
disabled: "text-text-dimmed opacity-50",
range_middle: "aria-selected:bg-charcoal-700 aria-selected:text-text-bright",
hidden: "invisible",
dropdowns: "flex gap-2 items-center justify-center",
dropdown:
"bg-charcoal-750 border border-charcoal-600 rounded px-2 py-1 text-sm text-text-bright focus:outline-none focus:border-charcoal-500",
...classNames,
}}
components={{
MonthCaption: CustomMonthCaption,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";

Some files were not shown because too many files have changed in this diff Show More