Compare commits

...

88 Commits

Author SHA1 Message Date
Matt Aitken 46bceb0064 Pin @types/react and @types/react-dom to 18.x to avoid TS errors from mixed versions 2026-03-25 13:46:11 +00:00
Eric Allam c00dae006b feat(mcp): add get_span_details tool (#3255)
Returns the fully detailed span with attributes and AI enrichment data
2026-03-24 13:18:49 +00:00
Eric Allam 774007e9b9 fix(mcp): get_run_details only caches completed runs (#3253) 2026-03-24 11:28:52 +00:00
Oskar Otwinowski f86e492c31 feat(vercel): Flow to support Vercel's template deployment (#3229) 2026-03-24 11:04:57 +01:00
Saadi Myftija d4772b5f60 feat: run annotations (#3241)
Adds an `annotations` JSONB column to task runs that captures where and
how each run was triggered.
This enables filtering and analyzing trigger origins without querying up
the run tree. Also enables making scheduling decisions based on the
trigger source, e.g., use separate affinities for scheduled runs.

Each run records:
- **triggerSource**: who initiated it (sdk, api, dashboard, cli, mcp,
schedule)
- **triggerAction**: what kind of action (trigger, replay, test)
- **rootTriggerSource**: the trigger source of the root ancestor,
propagated through the entire run
 tree
- **rootScheduleId**: schedule id, in case the run tree was triggered
from a schedule

Currently the main motivation for annotations it to determine whether a
run is part of a schedule-originated tree without traversing ancestors.

### A couple of design considerations
- **Decoupled source from method**: triggerSource and triggerAction are
separate fields to avoid
combinatorial explosion (every new source × every new action)
- **Server-side first**: all annotation values are primarily determined
on the server, only a minor SDK change needed
- **Forward-compatible**: annotation fields use
`z.enum([...]).or(anyString)` so new values can be
added without breaking validation; we currently don't need an explicit
version field for annotations.

Note: `metadata` would have been a more fitting name for the db column,
as it is consistent with other tables where we store this type of
information. It is already in use to store user metadata though, so we
go with `annotations` instead.
2026-03-23 16:07:30 +01:00
Matt Aitken 88f755082f Vouched: jrossi (#3247)
Fixes #3246
2026-03-23 12:18:27 +00:00
Eric Allam 54d95ee4b9 feat: AI prompt management dashboard and enhanced span inspectors (#3244)
- Full prompt management UI: list, detail, override, and version
management for AI prompts defined with `prompts.define()`
- Rich AI span inspectors for all AI SDK operations with token usage,
messages, and prompt context
- Real-time generation tracking with live polling and filtering

## Prompt management

Define prompts in your code with `prompts.define()`, then manage
versions and overrides from the dashboard without redeploying:

```typescript
import { task, prompts } from "@trigger.dev/sdk";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const supportPrompt = prompts.define({
  id: "customer-support",
  model: "gpt-4o",
  variables: z.object({
    customerName: z.string(),
    plan: z.string(),
    issue: z.string(),
  }),
  content: `You are a support agent for Acme SaaS.
Customer: {{customerName}} ({{plan}} plan)
Issue: {{issue}}
Respond with empathy and precision.`,
});

export const supportTask = task({
  id: "handle-support",
  run: async (payload) => {
    const resolved = await supportPrompt.resolve({
      customerName: payload.name,
      plan: payload.plan,
      issue: payload.issue,
    });

    const result = await generateText({
      model: openai(resolved.model ?? "gpt-4o"),
      system: resolved.text,
      prompt: payload.issue,
      ...resolved.toAISDKTelemetry(),
    });

    return { response: result.text };
  },
});
```

The prompts list page shows each prompt with its current version, model,
override status, and a usage sparkline over the last 24 hours.

From the prompt detail page you can:

- **Create overrides** to change the prompt template or model without
redeploying. Overrides take priority over the deployed version when
`prompt.resolve()` is called.
- **Promote** any code-deployed version to be the current version
- **Browse generations** across all versions with infinite scroll and
live polling for new results
- **Filter** by version, model, operation type, and provider
- **View metrics** (total generations, avg tokens, avg cost, latency)
broken down by version

## AI span inspectors

Every AI SDK operation now gets a custom inspector in the run trace
view:

- **`ai.generateText` / `ai.streamText`** — Shows model, token usage,
cost, the full message thread (system prompt, user message, assistant
response), and linked prompt details
- **`ai.generateObject` / `ai.streamObject`** — Same as above plus the
JSON schema and structured output
- **`ai.toolCall`** — Shows tool name, call ID, and input arguments
- **`ai.embed`** — Shows model and the text being embedded

For generation spans linked to a prompt, a "Prompt" tab shows the prompt
metadata, the input variables passed to `resolve()`, and the template
content from the prompt version.

All AI span inspectors include a compact timestamp and duration header.

## Other improvements

- Resizable panel sizes now persist across page refreshes (patched
`@window-splitter/state` to fix snapshot restoration)
- Run page panels also persist their sizes
- Fixed `<div>` inside `<p>` DOM nesting warnings in span titles and
chat messages
- Added Operations and Providers filters to the AI metrics dashboard

## Screenshots

<img width="3680" height="2392" alt="CleanShot 2026-03-21 at 10 14
17@2x"
src="https://github.com/user-attachments/assets/f3e59989-a2fa-4990-a9d0-3cacda431868"
/>

<img width="3680" height="2392" alt="CleanShot 2026-03-21 at 10 15
37@2x"
src="https://github.com/user-attachments/assets/2f2d02df-2d2b-44fb-ac6f-9153f6a6c387"
/>

<img width="3680" height="2392" alt="CleanShot 2026-03-21 at 10 15
54@2x"
src="https://github.com/user-attachments/assets/baa161e0-ef91-4fa4-a55f-986b71cccdf0"
/>
2026-03-23 06:23:19 +00:00
Matt Aitken 35298ac357 Impersonating run and clearing fix (#3144)
- Automatically impersonate a run when visiting /runs/<run_id> if an
admin is logged in
- Clear existing impersonation when switching
2026-03-18 14:36:27 +00:00
Eric Allam 1cfc296c6b feat(ai): LLM metrics tracking and AI span inspector (#3213)
- Automatic LLM cost enrichment for AI SDK spans (streamText,
generateText, generateObject) or any other spans that use semantic
gen_ai attributes with support for 145+ models
- New AI span inspector sidebar showing model, tokens, cost, messages,
tool calls, and response text
- LLM metrics dual-write to ClickHouse `llm_metrics_v1` table for
analytics
- LLM metrics built-in dashboard (unlinked at the moment)
- Provider cost fallback — uses gateway/OpenRouter reported costs from
`providerMetadata` when registry pricing is unavailable
- Prefix-stripping for gateway/OpenRouter model names (e.g.
`mistral/mistral-large-3` matches `mistral-large-3` pricing)
- Admin dashboard for managing LLM model pricing (list, create, edit,
delete, search, test pattern matching)
- Missing models detection page — queries ClickHouse for unpriced models
with sample spans and Claude Code-ready prompts for adding pricing
- AI span seed script (`pnpm run db:seed:ai-spans`) with 51 spans across
12 provider systems for local dev testing
- UI fixes: `completionTokens`/`promptTokens` aliases,
`ai.response.object` display for generateObject, cache read/write token
breakdown

## Screenshots:

<img width="1030" height="104" alt="CleanShot 2026-03-17 at 16 48 54@2x"
src="https://github.com/user-attachments/assets/bc8fccda-e48b-4d0c-bfb1-e620064e5979"
/>

<img width="1094" height="1512" alt="CleanShot 2026-03-17 at 16 49
23@2x"
src="https://github.com/user-attachments/assets/c2424569-d07e-4d67-a436-e8250043a1ee"
/>

<img width="1074" height="1412" alt="CleanShot 2026-03-17 at 16 49
18@2x"
src="https://github.com/user-attachments/assets/22342ac4-4769-45d1-a328-a24fb9a82a50"
/>

<img width="1012" height="2292" alt="CleanShot 2026-03-17 at 16 39
01@2x"
src="https://github.com/user-attachments/assets/59e327d1-6652-4293-8be0-bb8326e5fbc5"
/>

<img width="3680" height="2392" alt="CleanShot 2026-03-15 at 08 29
38@2x"
src="https://github.com/user-attachments/assets/1f77beb8-de67-495b-b890-bcdb8d7f1fe8"
/>

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-03-17 18:26:43 +00:00
Eric Allam 411803e49e fix(engine): lockless waitpoint insert for batch items to eliminate lock contention (#3232)
When processing batchTriggerAndWait items, each batch item was acquiring
a
Redis lock on the parent run to insert a TaskRunWaitpoint row. With high
concurrency (processingConcurrency=50), this caused
LockAcquisitionTimeoutError
(880 errors/24h in prod), orphaned runs, and stuck parent runs.

Since blockRunWithCreatedBatch already transitions the parent to
EXECUTING_WITH_WAITPOINTS before items are processed, the per-item lock
is
unnecessary. The new blockRunWithWaitpointLockless method performs only
the
idempotent CTE insert and timeout scheduling without acquiring the lock.
2026-03-17 16:42:01 +00:00
Eric Allam dbbe9f77f9 feat(cli): Expand and improve the MCP server and dev CLI command (#3224)
## Summary

Major expansion of the MCP server (14 → 25 tools), context efficiency
optimizations, new API endpoints, and a fix for the dev CLI leaking
build directories on disk.

### New MCP tools

- **Query & analytics**: `get_query_schema`, `query`, `list_dashboards`,
`run_dashboard_query` — query your data using TRQL directly from AI
assistants
- **Profile management**: `whoami`, `list_profiles`, `switch_profile` —
see and switch CLI profiles per-project (persisted to
`.trigger/mcp.json`)
- **Dev server control**: `start_dev_server`, `stop_dev_server`,
`dev_server_status` — start/stop `trigger dev` and stream build output
- **Task introspection**: `get_task_schema` — get payload schema for a
specific task (split out from `get_current_worker` to reduce context)

### New API endpoints

- `GET /api/v1/query/schema` — discover TRQL tables and columns
(server-driven, multi-table)
- `GET /api/v1/query/dashboards` — list built-in dashboard widgets and
their queries

### New features

- **`--readonly` flag** — hides write tools (`deploy`, `trigger_task`,
`cancel_run`) so agents can't make changes
- **`read:query` JWT scope** — new authorization scope for query
endpoints, with per-table granularity (`read:query:runs`,
`read:query:llm_metrics`, etc.)
- **Paginated trace output** — `get_run_details` now paginates trace
events via cursor, caching the full trace in a temp file so subsequent
pages don't re-fetch
- **MCP tool annotations** — all tools now have
`readOnlyHint`/`destructiveHint` annotations for clients that support
them
- **Project-scoped profile persistence** — `switch_profile` saves to
`.trigger/mcp.json` (gitignored), automatically loaded on next MCP
server start

### Context optimizations

- `get_query_schema` requires a table name — returns one table's schema
instead of all tables (60-80% fewer tokens)
- `get_current_worker` no longer inlines payload schemas — use
`get_task_schema` for specific tasks
- Query results formatted as text tables instead of JSON (~50% fewer
tokens for flat data)
- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as
text instead of raw `JSON.stringify()`
- Schema and dashboard API responses cached (1hr and 5min respectively)

### Bug fixes

- Fixed `search_docs` failing due to renamed upstream Mintlify tool
(`SearchTriggerDev` → `search_trigger_dev`)
- Fixed `list_deploys` failing when deployments have null
`runtime`/`runtimeVersion` fields (fixes #3139)
- Fixed `list_preview_branches` crashing due to incorrect response shape
access
- Fixed `metrics` table column documented as `value` instead of
`metric_value` in query docs
- Fixed `/api/v1/query` not accepting JWT auth (added `allowJWT: true`)

### Dev CLI build directory fix

The dev CLI was leaking `build-*` directories in `.trigger/tmp/` on
every rebuild, accumulating hundreds of MB over time (842MB observed).
Three layers of protection added:

1. **During session**: deprecated workers are pruned (capped at 2
retained) when no active runs reference them, preventing unbounded
accumulation
2. **On SIGKILL/crash**: the watchdog process now cleans up
`.trigger/tmp/` when it detects the parent CLI was killed
3. **On next startup**: existing `clearTmpDirs()` wipes any remaining
orphans

## Test plan

- [ ] `pnpm run mcp:smoke` — 17 automated smoke tests for all read-only
MCP tools
- [ ] `pnpm run mcp:test list` — verify 25 tools registered (21 in
`--readonly` mode)
- [ ] `pnpm run mcp:test --readonly list` — verify write tools hidden
- [ ] Manual: start dev server, trigger task, rebuild multiple times,
verify build dirs stay capped at 4
- [ ] Manual: SIGKILL the dev CLI, verify watchdog cleans up
`.trigger/tmp/`
- [ ] Verify new API endpoints return correct data: `GET
/api/v1/query/schema`, `GET /api/v1/query/dashboards`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-17 11:38:23 +00:00
Jack Cheng f98e274b06 feat(webapp): allow version downgrades via promote API (#3214) 2026-03-17 07:38:20 +00:00
Matt Aitken cf0e1ff91a Vouched: bhekanik (#3227) 2026-03-16 22:24:49 +00:00
Matt Aitken 2406e850eb RunEngine readme updates (#3223) 2026-03-16 18:52:21 +00:00
Eric Allam 7672e8d998 fix(run-queue): prevent concurrency keys from bloating master queue shards (#3219)
Queues with concurrency keys now appear as a single entry in the master
queue instead of one entry per key. This prevents high-CK-count tenants
from consuming the entire `parentQueueLimit` window and starving other
tenants on the same shard.

A new per-queue **CK index** (sorted set) tracks active concurrency key
sub-queues. The master queue gets one `:ck:*` wildcard entry per base
queue. Dequeuing from that entry round-robins across sub-queues,
maintaining per-CK concurrency tracking and fairness.

All existing operations (enqueue, dequeue, ack, nack, DLQ, TTL expiry)
are CK-index-aware and keep the index consistent. Old-format entries
drain naturally during rollout — no migration step needed, single
deploy.
2026-03-14 13:37:54 +00:00
Eric Allam 2c688ca3d4 chore(repo): Add new contributing guidelines and auto-close outside PRs open in "ready to review" status (#3218) 2026-03-14 10:23:34 +00:00
Eric Allam 59ff68b00c chore(vouch): add bharathkumar39293 (#3217) 2026-03-14 09:12:51 +00:00
Eric Allam 440c16c4aa chore(repo): add agentcrumbs.dev support (#3206) 2026-03-13 13:37:09 +00:00
Eric Allam d4d8d9fabc fix(engine): add additional error logging around triggering runs (#3211) 2026-03-13 07:18:43 +00:00
Eric Allam 8108683073 vouch chengzp (#3212) 2026-03-12 23:33:25 +00:00
James Ritchie fea8ae4e53 feat(webapp): self serve preview branches and team members (#3201)
## Adds 2 self serve features

### 1. self serve preview branches

- Copies the patterns of the self serve concurrency
- Self serve only available on Pro plan (otherwise you are linked to the
billing plans page)
- Global self serve branches limit: 180 (+20 for the Pro plan). It can
be overridden per Org
- You need to archive branches before reducing the number of extra
branches you're paying for
- Branches are removed immediately but remain billed until the end of
the billing cycle like extra concurrency

### 2. self serve team members

- Copies the patterns of the self serve concurrency
- Self serve only available on Pro plan (otherwise you are linked to the
billing plans page)
- Global self serve members is unlimited but can be limited with the
same env var quota and overridden per org if needed
- You need to remove team members before reducing the number of members
you pay for
- Team members are removed immediately but remain billed until the end
of the billing cycle like extra concurrency
2026-03-12 14:56:10 +00:00
Iss 21fdb528f5 docs: deprecate syncVercelEnvVars extension and add conflict warning (#3208)
Deprecates the syncVercelEnvVars build extension and adds warnings in
both the Vercel integration docs and the extension's own page to prevent
conflicts with the native env var sync
2026-03-11 17:56:37 -04:00
Dinko Osrecki b8766e7a27 fix(webapp): strip secure param from query ClickHouse URL (#3204) 2026-03-11 06:42:49 +00:00
Eric Allam fef6d18e8f chore: add @edosrecki to vouched contributors (#3203) 2026-03-11 06:33:58 +00:00
github-actions[bot] c0b6309c72 chore: release v4.4.3 (#3182)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 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
## Summary
2 new features, 2 improvements.

## Improvements
- Add syncSupabaseEnvVars to pull database connection strings and save
them as trigger.dev environment variables
([#3152](https://github.com/triggerdotdev/trigger.dev/pull/3152))
- Auto-cancel in-flight dev runs when the CLI exits, using a detached
watchdog process that survives pnpm SIGKILL
([#3191](https://github.com/triggerdotdev/trigger.dev/pull/3191))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- A new Errors page for viewing and tracking errors that cause runs to
fail
  
  - Errors are grouped using error fingerprinting
- View top errors for a time period, filter by task, or search the text
  - View occurrences over time
- View all the runs for an error and bulk replay them
([#3172](https://github.com/triggerdotdev/trigger.dev/pull/3172))
- Add sidebar tabs (Options, AI, Schema) to the Test page for schemaTask
payload generation and schema viewing.
([#3188](https://github.com/triggerdotdev/trigger.dev/pull/3188))

<details>
<summary>Raw changeset output</summary>

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

### Patch Changes

- Add syncSupabaseEnvVars to pull database connection strings and save
them as trigger.dev environment variables
([#3152](https://github.com/triggerdotdev/trigger.dev/pull/3152))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.3`

## trigger.dev@4.4.3

### Patch Changes

- Auto-cancel in-flight dev runs when the CLI exits, using a detached
watchdog process that survives pnpm SIGKILL
([#3191](https://github.com/triggerdotdev/trigger.dev/pull/3191))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.3`
    -   `@trigger.dev/build@4.4.3`
    -   `@trigger.dev/schema-to-json@4.4.3`

## @trigger.dev/core@4.4.3

### Patch Changes

- Auto-cancel in-flight dev runs when the CLI exits, using a detached
watchdog process that survives pnpm SIGKILL
([#3191](https://github.com/triggerdotdev/trigger.dev/pull/3191))

## @trigger.dev/python@4.4.3

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.4.3

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.4.3

### Patch Changes

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

</details>

---------

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-03-10 10:17:24 +00:00
Eric Allam 436f20efc6 feat(cli): auto-cancel dev runs on CLI exit via detached watchdog (#3191)
When the dev CLI exits (e.g. ctrl+c via pnpm), runs that were
mid-execution
previously stayed stuck in EXECUTING status for up to 5 minutes until
the
heartbeat timeout fired. Now they are cancelled within seconds.

The dev CLI spawns a lightweight detached watchdog process at startup.
The
watchdog monitors the CLI process ID and, when it detects the CLI has
exited,
calls a new POST /engine/v1/dev/disconnect endpoint to cancel all
in-flight
runs immediately (skipping PENDING_CANCEL since the worker is known to
be dead).

Watchdog design:
- Fully detached (detached: true, stdio: ignore, unref()) so it survives
  even when pnpm sends SIGKILL to the process tree
- Active run IDs maintained via atomic file write
(.trigger/active-runs.json)
- Single-instance guarantee via PID file (.trigger/watchdog.pid)
- Safety timeout: exits after 24 hours to prevent zombie processes
- On clean shutdown, the watchdog is killed (no disconnect needed)

Disconnect endpoint:
- Rate-limited: 5 calls/min per environment
- Capped at 500 runs per call
- Small counts (<= 25): cancelled inline with pMap concurrency 10
- Large counts: delegated to the bulk action system
- Uses finalizeRun: true to skip PENDING_CANCEL and go straight to
FINISHED

Run engine change:
- cancelRun() now respects finalizeRun when the run is in EXECUTING
status,
skipping the PENDING_CANCEL waiting state and going directly to FINISHED
2026-03-09 12:14:43 +00:00
James Ritchie d1ea8d8f74 Fix(webapp) onboarding fixes (#3189)
### Fixes and improvements to the onboarding questions: 

**This change is worth double checking @matt-aitken** 
- Update to the Button.tsx file: it now takes `isLoading` that shows a
spinner in the middle of the button (replacing the button text and any
icons) and sets it to `disabled`. It does this nicely by keeping the
button width the same so there's no layout shift.

**Other fixes**
- Fixes an issue where if you type a custom option in the "What
technologies do you use" question, it doesn't check the list to see if
it matches. Now it checks the box if you've typed an option from that
list.
- When we randomize the list of onboarding question options, we now
store the position they appeared in the list
2026-03-09 10:09:21 +00:00
Eric Allam e64b101138 feat(webapp): Add test payload AI generation to the test page based on payload schemas (#3188)
<img width="2191" height="1023" alt="CleanShot 2026-03-06 at 13 36 53"
src="https://github.com/user-attachments/assets/4eba0d1a-1528-49a3-be5b-6bde89030193"
/>
<img width="411" height="1069" alt="CleanShot 2026-03-06 at 13 37 28"
src="https://github.com/user-attachments/assets/e5f7bb9c-c894-41cc-9ca6-96b43fcf6005"
/>

Add a tabbed sidebar to the Test page for standard tasks, reusing the
ClientTabs pattern from the Query page.

- Options tab: existing sidebar content (machine, version, queue, etc.)
- AI tab: AI-powered payload generation with streaming, supports JSON
  Schema, inferred schema from recent runs, and task source code lookup
  via tool calling for tasks without schemas
- Schema tab: displays payload JSON Schema (from schemaTask), inferred
  schema (from recent runs via @jsonhero/schema-infer), or empty state
  with schemaTask docs and example code

Data layer changes:
- Surface payloadSchema and inferredPayloadSchema from TestTaskPresenter
- Add payloadSchema and fileId to WorkerDeploymentWithWorkerTasks type
- Decompress zlib-deflated source files for AI context
2026-03-06 15:31:16 +00:00
Eric Allam 6f6523ff78 chore(repo): remove unnecessary "trigger.dev v4.4.2" header from the release PR description (#3183) 2026-03-06 14:07:32 +00:00
Matt Aitken d30ed3421e Logs query use_query_condition_cache=1 (#3186)
In theory this will make Log queries faster
2026-03-06 10:24:43 +00:00
Matt Aitken 5f359be286 feature: Errors page (#3172)
A top-level Errors page that aggregates errors from failed runs with
occurrences metrics.


https://github.com/user-attachments/assets/8f0ef55e-90dd-4faa-9051-59f4665181e4

Errors are “fingerprinted” so similar errors are grouped together (e.g.
has an ID in the error message).

You can view an individual error to view a timeline of when it fired,
the runs, and bulk replay them.
2026-03-05 11:05:45 +00:00
Oskar Otwinowski e49ccc1226 feat(buildExtensions): syncSupabaseEnvVars build extension (#3152)
with docs
2026-03-05 11:04:26 +01:00
Eric Allam c01332297b docs: update batch trigger concurrency limits (#3171) 2026-03-04 11:50:59 +00:00
Eric Allam c5ce2976be fix: Add repo flag when for updating the docker link during release (#3170) 2026-03-04 11:13:47 +00:00
Eric Allam e954a2c97a docs: realtime input streams (#3153)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-04 09:55:54 +00:00
github-actions[bot] e36b75785e chore: release v4.4.2 (#3127)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 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
# trigger.dev v4.4.2

## Summary
2 new features, 2 improvements, 8 bug fixes.

## Improvements
- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

## Bug fixes
- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- Two-level tenant dispatch architecture for batch queue processing.
Replaces the
single master queue with a two-level index: a dispatch index (tenant →
shard)
and per-tenant queue indexes (tenant → queues). This enables O(1) tenant
selection and fair scheduling across tenants regardless of queue count.
Improves batch queue processing performance.
([#3133](https://github.com/triggerdotdev/trigger.dev/pull/3133))
- Add input streams with API routes for sending data to running tasks,
SSE reading, and waitpoint creation. Includes Redis cache for fast
`.send()` to `.wait()` bridging, dashboard span support for input stream
operations, and s2-lite support with configurable S2 endpoint, access
token skipping, and S2-Basin headers for self-hosted deployments. Adds
s2-lite to Docker Compose for local development.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Speed up batch queue processing by disabling cooloff and increasing
the batch queue processing concurrency limits on the cloud:
  
  - Pro plan: increase to 50 from 10.
  - Hobby plan: increase to 10 from 5.
- Free plan: increase to 5 from 1.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- Move batch queue global rate limiter from FairQueue claim phase to
BatchQueue worker queue consumer for accurate per-item rate limiting.
Add worker queue depth cap to prevent unbounded growth that could cause
visibility timeouts.
([#3166](https://github.com/triggerdotdev/trigger.dev/pull/3166))
- Fix a race condition in the waitpoint system where a run could be
blocked by a completed waitpoint but never be resumed because of a
PostgreSQL MVCC issue. This was most likely to occur when creating a
waitpoint via `wait.forToken()` at the same moment as completing the
token with `wait.completeToken()`. Other types of waitpoints (timed,
child runs) were not affected.
([#3075](https://github.com/triggerdotdev/trigger.dev/pull/3075))
- Fix metrics dashboard chart series colors going out of sync and
widgets not reloading stale data when scrolled back into view
([#3126](https://github.com/triggerdotdev/trigger.dev/pull/3126))
- Gracefully handle oversized batch items instead of aborting the
stream.
  
When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.
  
Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))
- Require the user is an admin during an impersonation session.
Previously only the impersonation cookie was checked; now the real
user's admin flag is verified on every request. If admin has been
revoked, the session falls back to the real user's ID.
([#3078](https://github.com/triggerdotdev/trigger.dev/pull/3078))

<details>
<summary>Raw changeset output</summary>

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

### Patch Changes

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

## trigger.dev@4.4.2

### Patch Changes

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

## @trigger.dev/python@4.4.2

### Patch Changes

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

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

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

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

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

### Patch Changes

- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/rsc@4.4.2

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.4.2

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

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

## @trigger.dev/core@4.4.2

</details>

---------

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-03-04 09:47:24 +00:00
Eric Allam dee6f1d09e fix(batch): move batch queue global rate limiter to worker consumer level (#3166)
The global rate limiter was being applied at the FairQueue claim phase,
consuming 1 token per queue-claim-attempt rather than per item
processed.
  With many small queues (each batch is its own queue), consumers burned
  through tokens on empty or single-item queues, causing aggressive
  throttling well below the intended items/sec limit.

  Changes:
- Move rate limiter from FairQueue claim phase to BatchQueue worker
queue
    consumer loop (before blockingPop), so each token = 1 item processed
  - Replace the FairQueue rate limiter with a worker queue depth cap to
    prevent unbounded growth that could cause visibility timeouts
- Add BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH env var (optional, disabled by
default)
2026-03-03 14:44:27 +00:00
James Ritchie b192b71b93 Feat(webapp): add create custom dashboard button to metrics page (#3095)
Adds a "Create custom dashboard" button to the top right of the metrics
dashboard

 
<img width="3546" height="1934" alt="CleanShot 2026-02-19 at 11 25
12@2x"
src="https://github.com/user-attachments/assets/0bb46ade-47c9-4396-b62a-f4801d7d90b4"
/>
2026-03-02 18:23:02 +00:00
Eric Allam a9163dfd9d chore(docker): Pin goose in Dockerfile to v3.26.0 (#3163)
The latest goose requires go version 1.25:
https://github.com/pressly/goose/releases/tag/v3.27.0
2026-03-02 17:09:46 +00:00
Eric Allam 540e1c86a4 feat: Input Streams - Bidirectional task communication (#3146)
Input streams enable sending typed data to executing tasks from external
callers — backends, frontends, or other tasks. This unlocks interactive
use cases like approval UIs, cancel buttons, chat interfaces, and
human-in-the-loop AI workflows where the task needs to receive data
while running.

Three consumption patterns inside a task:

* `.wait()` — Suspend the task until data arrives (process freed, most
efficient)
* `.once()` — Wait for the next message (process stays alive)
* `.on()` — Subscribe to a continuous stream of messages

One send pattern from outside:

* `.send(runId, data)` — Send typed data to a specific run's input
stream

## User-facing API

### Define a typed input stream

```ts
import { streams, task } from "@trigger.dev/sdk";

const approval = streams.input<{ approved: boolean; reviewer: string }>({ id: "approval" });
```

### Consume inside a task

```ts
export const myTask = task({
  id: "my-task",
  run: async () => {
    // Pattern 1: Suspend until data arrives (most efficient — frees the process)
    const result = await approval.wait({ timeout: "5m" });

    // Pattern 2: Wait for next message (process stays alive)
    const data = await approval.once().unwrap();

    // Pattern 3: Subscribe to multiple messages
    approval.on((data) => { /* handle each message */ });
  },
});
```

### Send from outside

```ts
// From a backend (using secret API key)
await approval.send(runId, { approved: true, reviewer: "alice" });

// From a frontend (using public JWT token from trigger response)
const { send } = useInputStreamSend("approval", runId, { accessToken });
send({ approved: true, reviewer: "alice" });
```

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 16:49:54 +00:00
Eric Allam 2af5c860de chore(repo): Dispatch new-release to www.trigger.dev repo to generate version changelog draft PR (#3162) 2026-03-02 16:47:34 +00:00
James Ritchie a09038b066 Feature(webapp): new User and Project onboarding questions (#3109)
- New User onboarding questions added and stored in a new
`onboardingData` col
- Keeps the same Org creation screen and stores the data in the same
format in same DB column
- New Org onboarding questions addded and stored in a new
`onboardingData` col


https://github.com/user-attachments/assets/244e4bae-f74d-4ed4-a545-92c9b927e98b

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-02 14:45:14 +00:00
Eric Allam 2135dc56d6 chore(claude): Improve claude code instructions (#3161)
Also includes a claude.md audit workflow for PRs
2026-03-02 12:42:05 +00:00
Saadi Myftija 4a0fb2fc06 ci: pin vouch action version (#3160)
Pins vouch actions to `c6d80ead49839655b61b422700b7a3bc9d0804a9`
(v1.4.2) in favor of security practices. We were previously using the
`@main` tag.

Also removes the checkout steps as they're not needed in these
workflows.
2026-03-02 10:35:42 +01:00
James Ritchie e1f8134f44 fix(webapp): Removes the collapsible option from the query inspector to avoid firefox bug (#3155)
There's a bug in react-window-splitter on Firefox. When trying to expand
the inspector panel in the query editor, it checks if the main panel has
space but gets an object instead of a number for the auto-sized
query-main panel. This causes the expand calculation to fail and it
snaps it back to collapsed.

I've removed this behavior for now as it's not an important feature.
2026-02-28 20:09:13 +00:00
Oskar Otwinowski 10d6f01843 feat(vercel): Vercel SDK fixes and correct env vars behavior for staging envs (#3149) 2026-02-28 07:41:19 +00:00
Iss 24b92d3b68 docs: added runtime error note for supabase edge function (#3140) 2026-02-27 10:59:04 -05:00
Eric Allam 8003923598 feat(server): Gracefully handle oversized batch items instead of aborting the stream (#3137)
Gracefully handle oversized batch items instead of aborting the stream.

When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.

Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
2026-02-27 10:11:42 +00:00
Eric Allam cff45664fc fix: legacy master queue drain should never re-add entries (#3142) 2026-02-27 10:11:31 +00:00
Iss 51b6c3a580 docs: added note about Prisma 7.x for TASK_RUN_STALLED_EXECUTING error (#3138) 2026-02-26 16:01:31 -05:00
James Ritchie d5a27f08ed Fix(webapp): change "metrics" to "dashboard" (#3136)
<img width="249" height="245" alt="CleanShot 2026-02-26 at 16 44 46"
src="https://github.com/user-attachments/assets/2e38b60c-0fe4-4b88-b9b9-71df82943ace"
/>
2026-02-26 16:56:07 +00:00
Matt Aitken 719a44da01 Better explanation of batch processing concurrency (#3135) 2026-02-26 14:27:21 +00:00
Iss 92dfeb37b3 docs: Add workaround for Homebrew Bun ENOENT error to Bun guide (#3125) 2026-02-26 08:25:17 -05:00
Eric Allam b1e78a6590 feat(batch-queue): two-level tenant dispatch for fair queue (#3133)
Replace flat master queue index with two-level tenant dispatch to fix
noisy neighbor problem. When a tenant has many queues at capacity, the
scheduler now iterates tenants (Level 1) not queues, then fetches
per-tenant queues (Level 2) only for eligible tenants.

Single-deploy migration: new enqueues write to dispatch indexes only,
consumer drains old master queue alongside new dispatch path until
empty.
2026-02-26 13:07:27 +00:00
Eric Allam 5612383684 chore(repo): Improve formatting of server entries in release notes (#3134) 2026-02-26 11:59:47 +00:00
Iss 4451fcb84c docs: Query page output dot notation and metadata availability (#3132)
Clarifies in the Query docs that run metadata is not available on the
Query page and that the output column is JSON, so dot notation (e.g.
output.externalId) should be used for selecting and filtering. Adds an
example that filters by an output field in WHERE
2026-02-25 17:22:08 -05:00
Iss 863dbe8d60 docs: document waitpoint token API endpoints (#3130)
Adds REST API documentation for the 5 waitpoint token endpoints
(`/api/v1/waitpoints/tokens`), including create, list, retrieve,
complete, and HTTP callback. Also adds the `publicAccessToken` security
scheme used by the complete endpoint.

<!-- mintlify-editor-comments:start -->
Mintlify
---
0 threads from 0 users in Mintlify

- No unresolved comments
<!-- mintlify-editor-comments:end -->

<!-- mintlify-comment-->

<a
href="https://dashboard.mintlify.com/trigger/trigger/editor/docs%2Fdocument-waitpoint-endpoints?source=pr_comment"
target="_blank" rel="noopener noreferrer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"><img
src="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"
alt="Open in Mintlify Editor"></picture></a>

<!-- /mintlify-comment -->
2026-02-25 12:52:14 -05:00
Eric Allam 39dd91b098 fix(engine) prevent MVCC race in blockRunWithWaitpoint pending check (#3075)
Split the CTE in blockRunWithWaitpoint so the pending waitpoint check
is a separate SQL statement. In READ COMMITTED isolation, each statement
gets its own snapshot, so a separate SELECT sees the latest committed
state from concurrent completeWaitpoint calls.

Previously, the CTE did INSERT + pending check in one statement (one
snapshot). If completeWaitpoint committed between the CTE start and
the SELECT, the SELECT would still see PENDING due to the stale
snapshot. Neither side would enqueue continueRunIfUnblocked, leaving
the run stuck forever.
2026-02-25 17:41:50 +00:00
Matt Aitken cf6b6e7063 Fix realtime connections pricing tier from 100 to 1000 (#3131)
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

Verified the pricing definition displays the correct tier amount on the
plan selection page.

---

## Changelog

Fixed incorrect pricing tier for additional realtime connections from
$10/month per 100 to $10/month per 1000.

---

## Screenshots

N/A

💯

https://claude.ai/code/session_015QrZZJHPWta3QCBnhX2Pff

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-25 17:34:02 +00:00
Eric Allam bed3789c31 fix(batch-queue): speed up batch queue processing by disabling cooloff and fixing retry race (#3079)
Fix slow fair queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.

Removed cooloff entirely from the batch queue
2026-02-25 17:33:01 +00:00
Eric Allam a482153365 feat(webapp): require the user is an admin during an impersonation session (#3078) 2026-02-25 16:07:10 +00:00
Eric Allam fe193418d0 chore(repo) auto-link server change entries to their PRs via GitHub API (#3129) 2026-02-25 16:02:32 +00:00
Matt Aitken e9fb8e3b52 Query fixes: stale widget fix, multiple series colors mismatch (#3126)
- Fix for series color assignment being out of sync with the graph
(ensures added series colors match their graph representation)
- Reload widgets when returning to screen if props changed (prevents
stale widgets after filtering and scrolling)
2026-02-25 15:50:17 +00:00
Eric Allam c05b30adfe chore(repo): fix enhanced release pr description to filter out dependency only updates (#3128) 2026-02-25 15:50:06 +00:00
Eric Allam f37bdaac84 fix(sdk): batch triggerAndWait variants now return correct run.taskIdentifier instead of unknown (#3080)
Fixes #2942
2026-02-25 15:37:04 +00:00
Eric Allam 3c0644a3b8 feat: unified GitHub release, server change tracking, and enhanced release PR (#3085)
- Add .server-changes/ convention for tracking server-only changes
- Create scripts/enhance-release-pr.mjs to deduplicate and categorize
changeset PR body
- Create scripts/generate-github-release.mjs to format unified GitHub
release body
- Change release.yml to create one unified GitHub release instead of
per-package releases
- Add update-release job to patch Docker image link after images are
pushed to GHCR
- Update changesets-pr.yml to trigger on .server-changes, enhance PR
body, and clean up consumed files
- Document server changes in CLAUDE.md, CONTRIBUTING.md, CHANGESETS.md,
and RELEASE.md
2026-02-25 13:54:19 +00:00
Eric Allam 19733c8338 docs(queues): Cover new queue limits and TTL system (#3030)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3030"
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-25 11:18:44 +00:00
Matt Aitken 9ba608d2cf TRQL function tests and fixes (#3076)
What changed
- Fixed some functions like dateAdd, toString, ifNotFinite
- Removed all functions that accept lambdas as they're not supported
(yet)
- Added tests for all TRQL functions that use ClickHouse
2026-02-24 19:44:07 +00:00
Iss 89c73ed8ba docs: document run result and batch API endpoints (#3121)
Adds OpenAPI specs and sidebar pages for four previously undocumented
public endpoints: retrieve
run result, per-task batch trigger, retrieve batch, and retrieve batch
results.
2026-02-24 13:30:16 -05:00
Iss 97bf89873e docs: document undocumented run API endpoints (#3120)
Adds API reference pages for three previously undocumented run
endpoints: retrieve run events, retrieve run trace, and add tags to a
run.
2026-02-24 10:49:39 -05:00
Iss b60788df82 docs: note that onCancel only runs during active execution (#3119)
Adds a warning to the onCancel docs clarifying that the hook only fires
when a run is actively executing
2026-02-23 21:24:38 +00:00
Eric Allam 6409fea6ac fix(engine): allow disabling the ttl system consumers independently from the whole system (#3115) 2026-02-23 16:39:30 +00:00
Eric Allam ae46e3f7c8 feat(server): New TTL system, enforce max queue length limits, lazy waitpoint creation (#2980)
This PR implements a new run TTL system and queue size limits to prevent
unbounded queue growth which should help prevent situations where queues
enter a "death spiral" where the queue will never be able to catch up.

The main/correct way to battle this situation is to enforce a maximum
TTL on all runs (e.g. up to 14 days) where runs that have been queued
for that maximum TTL will get auto-expired, making room for newer runs
to execute. This required creating a new TTL system that can handle
higher workloads and is now deeply integrated into the RunQueue. When
runs are enqueued with a TTL, they are added to their normal queue as
well as to the TTL queue. When runs are dequeued, they are removed from
both their normal queue and the TTL queue. If runs are dequeued by the
TTL system, they are removed from their normal queue. Both these
dequeues happen automatically so there is no race condition.

The TTL expiration system is also made reliable by expiring runs via a
Redis worker, which is enqueued to atomically inside the TTL dequeue lua
script.

### Optional associated waitpoints

Additionally, this PR implements an optimization where runs that aren't
triggered with a dependent parent run will no longer create an
associated waitpoint. Associated waitpoints are then lazily created if a
dependent run wants to wait for the child run post-facto (via debounce
or idempotency), which is a rare situation but is possible. This means
fewer waitpoint creations but also fewer waitpoint completions for runs
with no dependencies.

### Environment Queue Limits

Prevents any single queue growing too large by enforcing queue size
limits at trigger time.

- Queue size checks happen at trigger time - runs are rejected if queue
would exceed limit
- Dashboard UI shows queue limits on both the Queues page and a new
Limits page
- In-memory caching for queue size checks to reduce Redis load

### Batch trigger fixes

Currently when a batch item cannot be created for whatever reason (e.g.
queue limits) the run will never get created, which means a stalled run
if using `batchTriggerAndWait`. We've updated the system to handle this
differently: now when a batch item cannot be triggered and converted
into a run, we will eventually (after retrying 8 times up to 30s) we
will create a "pre-failed" run with the error details, correctly
resolving the batchTriggerAndWait.
2026-02-23 15:57:32 +00:00
Oskar Otwinowski 69dc7bcde8 feat(webapp): Vercel / Slack integrations improvements (#3108)
##  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

Slack + GitHub + Vercel + Builds + Deployments

---

## Changelog

Settings changes:
- Split general from integrations
- Add new Slack section to org level integrations
Vercel improvements:
- bugfix for TRIGGER_SECRET_KEY collision
- onboarding improvements for connecting to projects
- new loops event
Slack improvements:
- nicer alerts
Webhook/Email alerts:
- rich events with Github & Vercel integration data

---

## Screenshots


<img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34"
src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f"
/>
<img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54"
src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262"
/>
<img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46"
src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0"
/>
<img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24"
src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49"
src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04"
src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56"
src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c"
/>
<img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41"
src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06"
src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad"
/>

💯
2026-02-23 13:48:09 +00:00
Matt Aitken 3a7054628f Fix: show the deprecation panel if it's an old project and v3 (#3113)
Without doing an expensive query we can’t tell if it’s definitely a v3
projects – like getting run counts.
So let’s just assume if the project hasn’t been upgraded to v4 (by
running dev/deploy CLI with v4) AND the project is older than the v4
release then it’s v3.
2026-02-23 11:10:37 +00:00
Eric Allam 676525279a docs: otel metrics (#3096) 2026-02-20 16:51:48 +00:00
James Ritchie 79f8cdef72 Fix(webapp): logs button + logs table row link fix (#3107)
Small fixes and improvements to the logs page:

- Clicking the Run ID didn't open inspector
- Swapped the "open link in tab" icon with Runs icon
- Prevent tooltip hovering on Level info

<img width="350" height="206" alt="CleanShot 2026-02-20 at 10 00 37@2x"
src="https://github.com/user-attachments/assets/3e82f24a-c0a1-4c01-a8e9-9e06a8af982a"
/>
2026-02-20 16:44:55 +00:00
github-actions[bot] 98bf706437 chore: release v4.4.1 (#3100)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 6s
🚀 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.4.1

### Patch Changes

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

## trigger.dev@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/build@4.4.1`
    -   `@trigger.dev/core@4.4.1`
    -   `@trigger.dev/schema-to-json@4.4.1`

## @trigger.dev/python@4.4.1

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.4.1

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/core@4.4.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>
2026-02-20 16:40:06 +00:00
Iss 354363e408 docs: Vercel integration – marketplace link, Build options, Root Directory warning (#3097)
Adds a direct Vercel Marketplace link, documents configuring build
options via the project config page, and adds a warning and workaround
for projects using a Vercel Root Directory
2026-02-20 10:56:44 -05:00
Iss 525145def0 docs: v3 deprecation notice and Migrate using AI updates on migrating-from-v3 (#3098)
Adds a deprecation warning at the top of the migrating-from-v3 page and
updates the “Migrate using AI” prompt and intro
2026-02-20 10:55:48 -05:00
Eric Allam 23c327ea16 fix(metrics): disable collecting system and filesystem metrics in deployed workers (#3106) 2026-02-20 15:45:35 +00:00
Eric Allam d794101c67 fix(webapp): fix broken MFA by only committing one auth session set-cookie call (#3104)
Co-authored-by: Oskar Otwinowski <oskar.otwinowski@gmail.com>
2026-02-20 15:09:53 +00:00
Eric Allam f325638892 fix(tests): fix flaky getSnapshotsSince test (#3103) 2026-02-20 14:52:59 +00:00
Eric Allam 68e3f8c9db chore(clickhouse): more clickhouse migration conflict fixes (#3102) 2026-02-20 13:28:58 +00:00
Eric Allam 2071090042 chore(clickhouse): fix clickhouse migration version conflict (#3101) 2026-02-20 14:24:56 +01:00
Eric Allam 469b039090 feat: OTEL metrics pipeline for task workers (#3061)
- Adds an end-to-end OTEL metrics pipeline: task workers collect and
export metrics via OpenTelemetry, the webapp ingests them into
ClickHouse, and they're queryable through the existing dashboard query
engine
- Workers emit process CPU/memory metrics (via
`@opentelemetry/host-metrics`) and Node.js runtime metrics (event loop
utilization, event loop delay, heap usage)
- Users can create custom metrics in their tasks via
`otel.metrics.getMeter()` from `@trigger.dev/sdk`
- Metrics are automatically tagged with run context (run ID, task slug,
machine, worker version) so they can be sliced per-run, per-task, or
per-machine
- The TSQL query engine gains metrics table support with typed attribute
columns, `prettyFormat()` for human-readable values, and per-schema time
bucket thresholds
- Includes reference tasks
(`references/hello-world/src/trigger/metrics.ts`) demonstrating
CPU-intensive, memory-ramp, bursty workload, and custom metrics patterns

## What changed

### Metrics collection (packages/core, packages/cli-v3)
- **Metrics export pipeline** — `TracingSDK` now sets up a
`MeterProvider` with a `PeriodicExportingMetricReader` that chains
through `TaskContextMetricExporter` (adds run context attributes) and
`BufferingMetricExporter` (batches exports to reduce overhead)
- **Host metrics** — Enabled `@opentelemetry/host-metrics` for process
CPU, memory, and system-level metrics
- **Node.js runtime metrics** — New `nodejsRuntimeMetrics.ts` module
using `performance.eventLoopUtilization()`, `monitorEventLoopDelay()`,
and `process.memoryUsage()` to emit 6 observable gauges
- File system and diskio metrics
- **Custom metrics** — Exposed `otel.metrics` from `@trigger.dev/sdk` so
users can create counters, histograms, and gauges in their tasks
- **Machine ID** — Stable per-worker machine identifier for grouping
metrics
- **Dev worker** — Drops `system.*` metrics to reduce noise, keeps
sending metrics between runs in warm workers

### Metrics ingestion (apps/webapp)
- **OTEL endpoint** — `otel.v1.metrics.ts` accepts OTEL metric export
requests (JSON and protobuf), converts to ClickHouse rows
- **ClickHouse schema** — `017_create_metrics_v1.sql` with 10-second
aggregation buckets, JSON attributes column, 60-day TTLs

### Query engine (internal-packages/tsql, apps/webapp)
- **Metrics query schema** — Typed columns for metric attributes
(`task_identifier`, `run_id`, `machine_name`, `worker_version`, etc.)
extracted from the JSON attributes column
- **`prettyFormat()`** — TSQL function that annotates columns with
format hints (`bytes`, `percent`, `durationSeconds`) for frontend
rendering without changing the underlying data
- **Per-schema time buckets** — Different tables can define their own
time bucket thresholds (metrics uses tighter intervals than runs)
- **AI query integration** — The AI query service knows about the
metrics table and can generate metric queries
- **Chart improvements** — Better formatting for byte values,
percentages, and durations in charts and tables

### Reference project
- **`references/hello-world/src/trigger/metrics.ts`** — 6 example tasks:
`cpu-intensive`, `memory-ramp`, `bursty-workload`, `sustained-workload`,
`concurrent-load`, `custom-metrics`

## Test plan

- [ ] Build all packages and webapp
- [ ] Start dev worker with hello-world reference project
- [ ] Run `cpu-intensive`, `memory-ramp`, and `custom-metrics` tasks
- [ ] Verify metrics in ClickHouse: `SELECT DISTINCT metric_name FROM
metrics_v1`
- [ ] Query via dashboard AI: "show me CPU utilization over time"
- [ ] Verify `prettyFormat` renders correctly in chart tooltips and
table cells
- [ ] Confirm dev worker drops `system.*` metrics but keeps `process.*`
and `nodejs.*`
2026-02-20 13:16:34 +00:00
Eric Allam 38981f5e70 chore(docs): cover maxDelay debounce option (#2985)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2985">
  <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-19 13:50:33 +00:00
Matt Aitken 22505e9803 Query and Metrics docs (#3074)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 13:50:13 +00:00
546 changed files with 65654 additions and 4328 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Define and manage AI prompts with `prompts.define()`. Create typesafe prompt templates with variables, resolve them at runtime, and manage versions and overrides from the dashboard without redeploying.
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix dev CLI leaking build directories on rebuild, causing disk space accumulation. Deprecated workers are now pruned (capped at 2 retained) when no active runs reference them. The watchdog process also cleans up `.trigger/tmp/` when the dev CLI is killed ungracefully (e.g. SIGKILL from pnpm).
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields.
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata.
+11
View File
@@ -0,0 +1,11 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
Add `get_span_details` MCP tool for inspecting individual spans within a run trace.
- New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed)
- Span IDs now shown in `get_run_details` trace output for easy discovery
- New API endpoint `GET /api/v1/runs/:runId/spans/:spanId`
- New `retrieveSpan()` method on the API client
+42
View File
@@ -0,0 +1,42 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
MCP server improvements: new tools, bug fixes, and new flags.
**New tools:**
- `get_query_schema` — discover available TRQL tables and columns
- `query` — execute TRQL queries against your data
- `list_dashboards` — list built-in dashboards and their widgets
- `run_dashboard_query` — execute a single dashboard widget query
- `whoami` — show current profile, user, and API URL
- `list_profiles` — list all configured CLI profiles
- `switch_profile` — switch active profile for the MCP session
- `start_dev_server` — start `trigger dev` in the background and stream output
- `stop_dev_server` — stop the running dev server
- `dev_server_status` — check dev server status and view recent logs
**New API endpoints:**
- `GET /api/v1/query/schema` — query table schema discovery
- `GET /api/v1/query/dashboards` — list built-in dashboards
**New features:**
- `--readonly` flag hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes
- `read:query` JWT scope for query endpoint authorization
- `get_run_details` trace output is now paginated with cursor support
- MCP tool annotations (`readOnlyHint`, `destructiveHint`) for all tools
**Bug fixes:**
- Fixed `search_docs` tool failing due to renamed upstream Mintlify tool (`SearchTriggerDev``search_trigger_dev`)
- Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (#3139)
- Fixed `list_preview_branches` crashing due to incorrect response shape access
- Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs
- Fixed dev CLI leaking build directories on rebuild — deprecated workers now clean up their build dirs when their last run completes
**Context optimizations:**
- `get_query_schema` now requires a table name and returns only one table's schema (was returning all tables)
- `get_current_worker` no longer inlines payload schemas; use new `get_task_schema` tool instead
- Query results formatted as text tables instead of JSON (~50% fewer tokens)
- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw JSON
- Schema and dashboard API responses cached to avoid redundant fetches
+8
View File
@@ -0,0 +1,8 @@
---
"@trigger.dev/redis-worker": patch
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Adapted the CLI API client to propagate the trigger source via http headers.
+13
View File
@@ -0,0 +1,13 @@
---
paths:
- "internal-packages/database/**"
---
# Database Migration Safety
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
- Never drop columns or tables without explicit approval.
- New code should target `RunEngineVersion.V2` only.
+14
View File
@@ -0,0 +1,14 @@
---
paths:
- "docs/**"
---
# Documentation Writing Rules
- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional).
- After creating a new page, add it to `docs.json` navigation under the correct group.
- Use Mintlify components: `<Note>`, `<Warning>`, `<Info>`, `<Tip>`, `<CodeGroup>`, `<Expandable>`, `<Steps>`/`<Step>`.
- Code examples should be complete and runnable where possible.
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
- Keep paragraphs short. Use headers to break up content.
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
+33
View File
@@ -0,0 +1,33 @@
---
paths:
- "apps/webapp/app/v3/**"
---
# Legacy V1 Engine Code in `app/v3/`
The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code.
## V1-Only Files - Never Modify
- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub)
- `legacyRunEngineWorker.server.ts` (V1 background job worker)
- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering)
- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation)
- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer)
- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer)
## V1/V2 Branching Pattern
Some services act as routers that branch on `RunEngineVersion`:
- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2
- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2
When editing these shared services, only modify V2 code paths.
## V2 Modern Stack
- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine)
- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker)
- **Queue operations**: RunQueue inside run-engine (not MarQS)
- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts`
- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`
+12
View File
@@ -0,0 +1,12 @@
---
paths:
- "packages/**"
---
# Public Package Rules
- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add`
- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval.
- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`).
- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
- Test changes using `references/hello-world` reference project.
+23
View File
@@ -0,0 +1,23 @@
---
paths:
- "apps/**"
---
# Server App Changes
When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
```bash
cat > .server-changes/descriptive-name.md << 'EOF'
---
area: webapp
type: fix
---
Brief description of what changed and why.
EOF
```
- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
+5 -1
View File
@@ -13,4 +13,8 @@ samejr
isshaddad
# Outside contributors
gautamsi
capaj
capaj
chengzp
bharathkumar39293
bhekanik
jrossi
+30 -9
View File
@@ -7,6 +7,7 @@ on:
paths:
- "packages/**"
- ".changeset/**"
- ".server-changes/**"
- "package.json"
- "pnpm-lock.yaml"
@@ -50,7 +51,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update PR title with version
- name: Update PR title and enhance body
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -61,6 +62,15 @@ jobs:
# 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"
# Enhance the PR body with a clean, deduplicated summary
RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body')
ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION")
if [ -n "$ENHANCED_BODY" ]; then
gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \
-X PATCH \
-f body="$ENHANCED_BODY"
fi
fi
update-lockfile:
@@ -88,15 +98,26 @@ jobs:
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
- name: Commit and push lockfile
- name: Clean up consumed .server-changes/ files
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"
shopt -s nullglob
files=(.server-changes/*.md)
for f in "${files[@]}"; do
if [ "$(basename "$f")" != "README.md" ]; then
git rm --ignore-unmatch "$f"
fi
done
- name: Commit and push lockfile + server-changes cleanup
run: |
set -e
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git commit -m "chore: update lockfile and clean up .server-changes/ for release"
git push origin changeset-release/main
else
echo "No changes to commit"
fi
+69
View File
@@ -0,0 +1,69 @@
name: 📝 CLAUDE.md Audit
on:
pull_request:
types: [opened, ready_for_review, synchronize]
paths-ignore:
- "docs/**"
- ".changeset/**"
- ".server-changes/**"
- "**/*.md"
- "references/**"
concurrency:
group: claude-md-audit-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
audit:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
use_sticky_comment: true
claude_args: |
--max-turns 15
--allowedTools "Read,Glob,Grep,Bash(git diff:*)"
prompt: |
You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating.
## Your task
1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory.
3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider:
- New files/directories that aren't covered by existing documentation
- Changed architecture or patterns that contradict current CLAUDE.md guidance
- New dependencies, services, or infrastructure that Claude should know about
- Renamed or moved files that are referenced in CLAUDE.md
- Changes to build commands, test patterns, or development workflows
## Response format
If NO updates are needed, respond with exactly:
✅ CLAUDE.md files look current for this PR.
If updates ARE needed, respond with a short list:
📝 **CLAUDE.md updates suggested:**
- `path/to/CLAUDE.md`: [what should be added/changed]
- `.claude/rules/file.md`: [what should be added/changed]
Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions.
Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns).
Do NOT suggest creating new CLAUDE.md files - only updates to existing ones.
+70 -1
View File
@@ -111,7 +111,7 @@ jobs:
uses: changesets/action@v1
with:
publish: pnpm run changeset:release
createGithubReleases: true
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -122,6 +122,19 @@ jobs:
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
- name: Create unified GitHub release
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
VERSION="${{ steps.get_version.outputs.package_version }}"
node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md
gh release create "v${VERSION}" \
--title "trigger.dev v${VERSION}" \
--notes-file /tmp/release-body.md \
--target main
- name: Create and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
@@ -140,6 +153,62 @@ jobs:
with:
image_tag: v${{ needs.release.outputs.published_package_version }}
# After Docker images are published, update the GitHub release with the exact GHCR tag URL.
# The GHCR package version ID is only known after the image is pushed, so we query for it here.
update-release:
name: 🔗 Update release Docker link
needs: [release, publish-docker]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
packages: read
steps:
- name: Update GitHub release with Docker image link
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
VERSION="${{ needs.release.outputs.published_package_version }}"
TAG="v${VERSION}"
# Query GHCR for the version ID matching this tag
VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \
/orgs/triggerdotdev/packages/container/trigger.dev/versions \
--jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \
| head -1)
if [ -z "$VERSION_ID" ]; then
echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update"
exit 0
fi
DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}"
GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev"
# Get current release body and replace the generic link with the tag-specific one.
# Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that
# already have a version ID appended (idempotent on re-runs).
gh release view "${TAG}" --repo triggerdotdev/trigger.dev --json body --jq '.body' > /tmp/release-body.md
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
gh release edit "${TAG}" --repo triggerdotdev/trigger.dev --notes-file /tmp/release-body.md
# Dispatch changelog entry creation to the marketing site repo.
# Runs after update-release so the GitHub release body already has the exact Docker image URL.
dispatch-changelog:
name: 📝 Dispatch changelog PR
needs: [release, update-release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CROSS_REPO_PAT }}
repository: triggerdotdev/trigger.dev-site-v3
event-type: new-release
client-payload: '{"version": "${{ 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
+24 -3
View File
@@ -10,14 +10,35 @@ permissions:
issues: read
jobs:
check-pr:
check-vouch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/check-pr@main
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
require-vouch: true
env:
GH_TOKEN: ${{ github.token }}
require-draft:
needs: check-vouch
if: >
github.event.pull_request.draft == false &&
github.event.pull_request.author_association != 'MEMBER' &&
github.event.pull_request.author_association != 'OWNER' &&
github.event.pull_request.author_association != 'COLLABORATOR'
runs-on: ubuntu-latest
steps:
- name: Close non-draft PR
env:
GH_TOKEN: ${{ github.token }}
run: |
STATE=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json state -q '.state')
if [ "$STATE" != "OPEN" ]; then
echo "PR is already closed, skipping."
exit 0
fi
gh pr close ${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--comment "Thanks for your contribution! We require all external PRs to be opened in **draft** status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See [CONTRIBUTING.md](https://github.com/${{ github.repository }}/blob/main/CONTRIBUTING.md#pr-workflow) for details."
+1 -2
View File
@@ -16,8 +16,7 @@ jobs:
contains(github.event.comment.body, 'denounce') ||
contains(github.event.comment.body, 'unvouch')
steps:
- uses: actions/checkout@v4
- uses: mitchellh/vouch/action/manage-by-issue@main
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
comment-id: ${{ github.event.comment.id }}
issue-id: ${{ github.event.issue.number }}
+2 -1
View File
@@ -67,4 +67,5 @@ apps/**/public/build
**/.claude/settings.local.json
.mcp.log
.mcp.json
.cursor/debug.log
.cursor/debug.log
ailogger-output.log
View File
+81
View File
@@ -0,0 +1,81 @@
# Server Changes
This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented.
## When to add a file
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
**Package-only PRs**: Just add a changeset as usual.
## File format
Create a markdown file with a descriptive name:
```
.server-changes/fix-batch-queue-stalls.md
```
With this format:
```markdown
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
```
### Fields
- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type** (required): `feature` | `fix` | `improvement` | `breaking`
### Description
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
## Lifecycle
1. Engineer adds a `.server-changes/` file in their PR
2. Files accumulate on `main` as PRs merge
3. The changeset release PR includes these in its summary
4. After the release merges, CI cleans up the consumed files
## Examples
**New feature:**
```markdown
---
area: webapp
type: feature
---
TRQL query language and the Query page
```
**Bug fix:**
```markdown
---
area: webapp
type: fix
---
Fix schedule limit counting for orgs with custom limits
```
**Improvement:**
```markdown
---
area: webapp
type: improvement
---
Use the replica for API auth queries to reduce primary load
```
+30
View File
@@ -0,0 +1,30 @@
---
area: webapp
type: feature
---
AI prompt management dashboard and enhanced span inspectors.
**Prompt management:**
- Prompts list page with version status, model, override indicators, and 24h usage sparklines
- Prompt detail page with template viewer, variable preview, version history timeline, and override editor
- Create, edit, and remove overrides to change prompt content or model without redeploying
- Promote any code-deployed version to current
- Generations tab with infinite scroll, live polling, and inline span inspector
- Per-prompt metrics: total generations, avg tokens, avg cost, latency, with version-level breakdowns
**AI span inspectors:**
- Custom inspectors for `ai.generateText`, `ai.streamText`, `ai.generateObject`, `ai.streamObject` parent spans
- `ai.toolCall` inspector showing tool name, call ID, and input arguments
- `ai.embed` inspector showing model, provider, and input text
- Prompt tab on AI spans linking to prompt version with template and input variables
- Compact timestamp and duration header on all AI span inspectors
**AI metrics dashboard:**
- Operations, Providers, and Prompts filters on the AI Metrics dashboard
- Cost by prompt widget
- "AI" section in the sidebar with Prompts and AI Metrics links
**Other improvements:**
- Resizable panel sizes now persist across page refreshes
- Fixed `<div>` inside `<p>` DOM nesting warnings in span titles and chat messages
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add allowRollbacks query param to the promote deployment API to enable version downgrades
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Concurrency-keyed queues now use a single master queue entry per base queue instead of one entry per key. Prevents high-CK-count tenants from consuming the entire parentQueueLimit window and starving other tenants on the same shard.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Added `/engine/v1/dev/disconnect` endpoint to auto-cancel runs when the CLI disconnects. Maximum of 500 runs can be cancelled. Uses the bulk action system when there are more than 25 runs to cancel.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Reduce lock contention when processing large `batchTriggerAndWait` batches. Previously, each batch item acquired a Redis lock on the parent run to insert a `TaskRunWaitpoint` row, causing `LockAcquisitionTimeoutError` with high concurrency (880 errors/24h in prod). Since `blockRunWithCreatedBatch` already transitions the parent to `EXECUTING_WITH_WAITPOINTS` before items are processed, the per-item lock is unnecessary. The new `blockRunWithWaitpointLockless` method performs only the idempotent CTE insert without acquiring the lock.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Strip `secure` query parameter from QUERY_CLICKHOUSE_URL before passing to ClickHouse client. This was already done for the main and logs ClickHouse clients but was missing for the query client, causing a startup crash with `Error: Unknown URL parameters: secure`.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_metrics_v1` ClickHouse table that captures usage, cost, performance (TTFC, tokens/sec), and behavioral (finish reason, operation type) metrics.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add API endpoint `GET /api/v1/runs/:runId/spans/:spanId` that returns detailed span information including properties, events, AI enrichment (model, tokens, cost), and triggered child runs.
+2
View File
@@ -0,0 +1,2 @@
vouch:
- github: edosrecki
+34 -9
View File
@@ -1,24 +1,49 @@
# Changesets
# Changesets and Server Changes
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm.
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention.
## Adding a changeset
## Adding a changeset (package changes)
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
## Release instructions (local only)
## Adding a server change (server-only changes)
Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
1. Run `pnpm run changeset:version`
2. Run `pnpm run changeset:release`
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type`: `feature` | `fix` | `improvement` | `breaking`
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
See `.server-changes/README.md` for full documentation.
## When to add which
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
## Release instructions (CI)
Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly:
- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm.
- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries.
- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main.
- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release.
## Pre-release instructions
+130 -190
View File
@@ -1,73 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas.
## 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
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
pnpm run db:migrate # Run database migrations
pnpm run db:seed # Seed the database (required for reference projects)
# 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/*"
pnpm run dev --filter webapp # Run webapp (http://localhost:3030)
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages
```
### Testing
### Verifying Changes
The verification command depends on where the change lives:
- **Apps and internal packages** (`apps/*`, `internal-packages/*`): Use `typecheck`. **Never use `build`** for these — building proves almost nothing about correctness.
- **Public packages** (`packages/*`): Use `build`.
```bash
# Apps and internal packages — use typecheck
pnpm run typecheck --filter webapp # ~1-2 minutes
pnpm run typecheck --filter @internal/run-engine
# Public packages — use build
pnpm run build --filter @trigger.dev/sdk
pnpm run build --filter @trigger.dev/core
```
Only run typecheck/build after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues.
## 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)
pnpm run test --filter webapp # All tests for a package
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
pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file
pnpm run build --filter @internal/run-engine # May need to build deps first
```
Test files go next to source files (e.g., `MyService.ts` `MyService.test.ts`).
Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`).
#### Testcontainers for Redis/PostgreSQL
### 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
## Changesets and Server Changes
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
@@ -77,227 +76,168 @@ 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
- **Never** select major without explicit approval
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
## Architecture Overview
### Request Flow
User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state)
### 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.
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
- **apps/supervisor**: Manages task execution containers (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/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks
- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images
- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root).
- **packages/build** (`@trigger.dev/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
- **packages/redis-worker** (`@trigger.dev/redis-worker`): 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)
- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL)
- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries
- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management
- **internal-packages/redis**: Redis client creation utilities (ioredis)
- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers
- **internal-packages/schedule-engine**: Durable cron scheduling
- **internal-packages/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
### Legacy V1 Engine Code
The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker.
### Documentation
Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions.
### 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.
The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs.
## Docker Image Guidelines
When updating Docker image references in `docker/Dockerfile` or other container files:
When updating Docker image references:
- **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
- Architecture-specific digests cause CI failures on different build environments
- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant
## Writing Trigger.dev Tasks
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
```typescript
import { task } from "@trigger.dev/sdk";
// Every task must be exported
export const myTask = task({
id: "my-task", // Unique ID
id: "my-task",
run: async (payload: { message: string }) => {
// Task logic - no timeouts
// Task logic
},
});
```
### 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.
The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes.
## Testing with hello-world Reference Project
First-time setup:
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
1. `pnpm run db:seed` to seed the database
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`
3. Authorize: `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
```
Running: `cd references/hello-world && pnpm exec trigger dev`
## 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
curl -s http://localhost:3030/healthcheck # Verify running
```
### 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
# Wait for "Local worker ready [node]"
```
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
)
mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"})
mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5)
```
### Step 4: Monitor Execution
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
- 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
<!-- intent-skills:start -->
### Key Project Refs
# Skill mappings — when working in these areas, load the linked skill file into context.
- hello-world: `proj_rrkpdguyagvsoktglnod`
skills:
- task: "Using agentcrumbs for debug tracing, adding crumbs, trails, markers, querying traces, or stripping debug code before merge"
load: "node_modules/agentcrumbs/skills/agentcrumbs/SKILL.md"
- task: "Setting up agentcrumbs in the project, initializing namespace catalog, running crumbs init"
load: "node_modules/agentcrumbs/skills/agentcrumbs/init/SKILL.md"
<!-- intent-skills:end -->
## agentcrumbs
Add crumbs as you write code — not just when debugging. Mark lines with
`// @crumbs` or wrap blocks in `// #region @crumbs`. They stay on the
branch throughout development and are stripped by `agentcrumbs strip`
before merge.
### Namespaces
| Namespace | Description | Path |
| ----------------- | ---------------------------------------------- | ----------------------------------- |
| `webapp` | Main Remix app — API, dashboard, orchestration | `apps/webapp` |
| `supervisor` | Task execution container manager | `apps/supervisor` |
| `sdk` | Main SDK for writing tasks | `packages/trigger-sdk` |
| `cli` | CLI and task image bundling | `packages/cli-v3` |
| `core` | Shared types and subpath exports | `packages/core` |
| `build` | Build extensions and types | `packages/build` |
| `react-hooks` | React hooks for realtime/triggering | `packages/react-hooks` |
| `redis-worker` | Redis-based background job system | `packages/redis-worker` |
| `run-engine` | Run Engine 2.0 — core run lifecycle | `internal-packages/run-engine` |
| `database` | Prisma client and schema | `internal-packages/database` |
| `clickhouse` | ClickHouse client, migrations, analytics | `internal-packages/clickhouse` |
| `schedule-engine` | Durable cron scheduling | `internal-packages/schedule-engine` |
| `run-queue` | Run queue management | `internal-packages/run-queue` |
Do not invent new namespaces — pick from this table or ask first.
### For PR reviewers
Do not flag `// @crumbs` markers, `#region @crumbs` blocks, or agentcrumbs
imports in reviews. These are temporary debug instrumentation stripped
before merge. Data logged in crumbs (IDs, names, values) never reaches
production.
### CLI
```bash
pnpm exec agentcrumbs collect # start collector (multi-service)
pnpm exec agentcrumbs tail --app trigger # live tail
pnpm exec agentcrumbs clear --app trigger # remove crumbs before merge
```
The preferred way to query for crumbs is to use `pnpm exec agentcrumbs query --app trigger` with the `--limit` option and cursor pagination, and clear existing crumbs before reproducing a bug via `pnpm exec agentcrumbs clear --app trigger`.
+50 -3
View File
@@ -242,9 +242,23 @@ See the [Job Catalog](./references/job-catalog/README.md) file for more.
**If you get errors, be sure to fix them before committing.**
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
> **Note:** We may close PRs if we decide that the cost of integrating the change outweighs the benefits. To improve the chances of your PR getting accepted, follow the guidelines below.
### PR workflow
1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete.
2. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed.
3. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green.
4. **Then mark the PR as "Ready for Review"** so a maintainer can take a look.
### Cost/benefit analysis for risky changes
If your change touches core infrastructure, modifies widely-used code paths, or could introduce regressions, consider doing a brief cost/benefit analysis and including it in the PR description. Explain what the benefit is to users and why the risk is worth it. This goes a long way toward helping maintainers evaluate your contribution.
### General guidelines
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.
## Adding changesets
@@ -267,6 +281,39 @@ You will be prompted to select which packages to include in the changeset. Only
Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review.
## Adding server changes
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
Create a markdown file with a descriptive name:
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
**Fields:**
- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type` (required): `feature` | `fix` | `improvement` | `breaking`
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
**When to add which:**
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
See `.server-changes/README.md` for more details.
## Troubleshooting
### EADDRINUSE: address already in use :::3030
+23
View File
@@ -1,5 +1,28 @@
## Guide on releasing a new version
### Automated release (v4+)
Releases are fully automated via CI:
1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes).
2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up).
3. When ready to release, merge the changeset release PR into `main`.
4. The [release.yml](./.github/workflows/release.yml) workflow automatically:
- Publishes all packages to npm
- Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4")
- Tags and triggers Docker image builds
- After Docker images are pushed, updates the GitHub release with the exact GHCR tag link
### What engineers need to do
- **Package changes**: Add a changeset with `pnpm run changeset:add`
- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`)
- **Mixed PRs**: Just the changeset is enough
See `CHANGESETS.md` for full details on changesets and server changes.
### Legacy release (v3)
1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge.
2. Pull the changes locally into main
3. Run `pnpm i` which will update the pnpm lock file with the new versions
View File
+20
View File
@@ -0,0 +1,20 @@
# Supervisor
Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results.
## Key Directories
- `src/services/` - Core service logic
- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes)
- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots)
- `src/clients/` - Platform communication (webapp/coordinator)
- `src/env.ts` - Environment configuration
## Architecture
- **WorkloadManager**: Abstracts Docker vs Kubernetes execution
- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling
- **ResourceMonitor**: Tracks CPU/memory during execution
- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup
Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp.
+100
View File
@@ -0,0 +1,100 @@
# Webapp
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
## Verifying Changes
**Never run `pnpm run build --filter webapp` to verify changes.** Building proves almost nothing about correctness. The webapp is an app, not a public package — use typecheck from the repo root:
```bash
pnpm run typecheck --filter webapp # ~1-2 minutes
```
Only run typecheck after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues.
Note: Public packages (`packages/*`) use `build` instead. See the root CLAUDE.md for details.
## Testing Dashboard Changes with Chrome DevTools MCP
Use the `chrome-devtools` MCP server to visually verify local dashboard changes. The webapp must be running (`pnpm run dev --filter webapp` from repo root).
### Login
```
1. mcp__chrome-devtools__new_page(url: "http://localhost:3030")
→ Redirects to /login
2. mcp__chrome-devtools__click the "Continue with Email" link
3. mcp__chrome-devtools__fill the email field with "local@trigger.dev"
4. mcp__chrome-devtools__click "Send a magic link"
→ Auto-logs in and redirects to the dashboard (no email verification needed locally)
```
### Navigating and Verifying
- **take_snapshot**: Get an a11y tree of the page (text content, element UIDs for interaction). Prefer this over screenshots for understanding page structure.
- **take_screenshot**: Capture what the page looks like visually. Use to verify styling, layout, and visual changes.
- **navigate_page**: Go to specific URLs, e.g. `http://localhost:3030/orgs/references-bc08/projects/hello-world-SiWs/env/dev/runs`
- **click / fill**: Interact with elements using UIDs from `take_snapshot`.
- **evaluate_script**: Run JS in the browser console for debugging.
- **list_console_messages**: Check for console errors after navigating.
### Tips
- Snapshots can be very large on complex pages (200K+ chars). Use `take_screenshot` first to orient, then `take_snapshot` only when you need element UIDs to interact.
- The local seeded user email is `local@trigger.dev`.
- Dashboard URL pattern: `http://localhost:3030/orgs/{orgSlug}/projects/{projectSlug}/env/{envSlug}/{section}`
## Key File Locations
- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts`
- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts`
- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts`
- **Prisma setup**: `app/db.server.ts`
- **Run engine config**: `app/v3/runEngine.server.ts`
- **Services**: `app/v3/services/**/*.server.ts`
- **Presenters**: `app/v3/presenters/**/*.server.ts`
## Route Convention
Routes use Remix flat-file convention with dot-separated segments:
`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger`
## Environment Variables
Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.**
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead:
- `realtimeClient.server.ts` (testable service, takes config as constructor arg)
- `realtimeClientGlobal.server.ts` (creates singleton with env config)
## Run Engine 2.0
The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2.
## Background Workers
Background job workers use `@trigger.dev/redis-worker`:
- `app/v3/commonWorker.server.ts`
- `app/v3/alertsWorker.server.ts`
- `app/v3/batchTriggerWorker.server.ts`
Do NOT add new jobs using zodworker/graphile-worker (legacy).
## Real-time
- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts`
- Electric SQL: Powers real-time data sync for the dashboard
## Legacy V1 Code
The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy:
- `app/v3/marqs/` (old MarQS queue system)
- `app/v3/legacyRunEngineWorker.server.ts`
- `app/v3/services/triggerTaskV1.server.ts`
- `app/v3/services/cancelTaskRunV1.server.ts`
- `app/v3/authenticatedSocketConnection.server.ts`
- `app/v3/sharedSocketConnection.ts`
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
@@ -0,0 +1,177 @@
type IconProps = { className?: string };
export function OpenAIIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.648 9.27004V7.36778C9.648 7.20757 9.70764 7.08738 9.84657 7.00738L13.6396 4.80477C14.1559 4.50443 14.7715 4.36434 15.4069 4.36434C17.7899 4.36434 19.2992 6.22658 19.2992 8.20884C19.2992 8.34898 19.2992 8.50919 19.2793 8.6694L15.3473 6.3466C15.1091 6.20651 14.8707 6.20651 14.6324 6.3466L9.648 9.27004ZM18.5048 16.6789V12.1334C18.5048 11.853 18.3855 11.6528 18.1473 11.5126L13.1629 8.58919L14.7913 7.64801C14.9303 7.56801 15.0495 7.56801 15.1884 7.64801L18.9814 9.85062C20.0737 10.4915 20.8084 11.853 20.8084 13.1745C20.8084 14.6962 19.9148 16.098 18.5048 16.6787V16.6789ZM8.47638 12.6742L6.848 11.7131C6.70907 11.6331 6.64943 11.5128 6.64943 11.3526V6.94746C6.64943 4.80498 8.2778 3.18295 10.4821 3.18295C11.3163 3.18295 12.0906 3.46334 12.7461 3.96392L8.834 6.24669C8.59578 6.38679 8.47658 6.58702 8.47658 6.86746V12.6743L8.47638 12.6742ZM11.9814 14.7165L9.648 13.395V10.5918L11.9814 9.27025L14.3146 10.5918V13.395L11.9814 14.7165ZM13.4807 20.8038C12.6466 20.8038 11.8723 20.5234 11.2168 20.0229L15.1288 17.7401C15.3671 17.6 15.4863 17.3997 15.4863 17.1193V11.3124L17.1346 12.2735C17.2735 12.3535 17.3331 12.4737 17.3331 12.634V17.0391C17.3331 19.1816 15.6848 20.8036 13.4807 20.8036V20.8038ZM8.77424 16.3385L4.9812 14.136C3.88892 13.4951 3.15426 12.1336 3.15426 10.8121C3.15426 9.27025 4.06775 7.88863 5.4776 7.30789V11.8733C5.4776 12.1537 5.59683 12.3539 5.83506 12.494L10.7997 15.3974L9.17134 16.3385C9.03241 16.4185 8.91317 16.4185 8.77424 16.3385ZM8.55592 19.6224C6.31192 19.6224 4.66364 17.9204 4.66364 15.8179C4.66364 15.6577 4.68355 15.4975 4.70329 15.3373L8.61535 17.62C8.85358 17.7602 9.09201 17.7602 9.33023 17.62L14.3146 14.7167V16.619C14.3146 16.7792 14.255 16.8994 14.1161 16.9794L10.3231 19.182C9.80672 19.4823 9.19109 19.6224 8.55571 19.6224H8.55592ZM13.4807 22.0052C15.8836 22.0052 17.8891 20.2832 18.3461 18.0004C20.5701 17.4197 21.9999 15.3172 21.9999 13.1747C21.9999 11.773 21.4043 10.4115 20.3319 9.43025C20.4312 9.00972 20.4908 8.58919 20.4908 8.16886C20.4908 5.30551 18.1872 3.16283 15.5261 3.16283C14.9901 3.16283 14.4737 3.24283 13.9574 3.42316C13.0636 2.54207 11.8324 1.98145 10.4821 1.98145C8.07927 1.98145 6.07369 3.70339 5.61678 5.98616C3.39269 6.5669 1.96289 8.6694 1.96289 10.8119C1.96289 12.2136 2.55857 13.5751 3.63095 14.5563C3.53166 14.9768 3.47207 15.3974 3.47207 15.8177C3.47207 18.6811 5.77567 20.8237 8.43668 20.8237C8.97277 20.8237 9.48911 20.7437 10.0055 20.5634C10.899 21.4445 12.1302 22.0052 13.4807 22.0052Z"
fill="currentColor"
/>
</svg>
);
}
export function AnthropicIcon({ className }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg>
);
}
export function GeminiIcon({ className }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12Z" />
</svg>
);
}
export function LlamaIcon({ className }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 12 12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.4485 2C4.406 2 5.2065 2.466 6.1635 3.688L6.3045 3.5015C6.3995 3.3785 6.496 3.2595 6.5945 3.1465L6.751 2.9715C7.294 2.394 7.896 2 8.6125 2C9.249 2 9.847 2.2785 10.358 2.758L10.467 2.8645C11.332 3.747 11.9255 5.2195 11.9935 6.8775L11.999 7.0735L12 7.1985C12 7.949 11.86 8.578 11.591 9.0485L11.521 9.1635L11.467 9.24C11.3165 9.45 11.135 9.619 10.924 9.7445L10.7915 9.8155L10.748 9.8355C10.6986 9.85749 10.6482 9.87718 10.597 9.8945C10.3825 9.96542 10.1579 10.0006 9.932 9.9985C9.67 9.9985 9.434 9.965 9.213 9.891C8.906 9.789 8.6315 9.611 8.35 9.333L8.2365 9.2155C7.86 8.8095 7.4695 8.2275 6.99 7.4225L6.275 6.2175L6.003 5.77L5.12 7.335L4.9485 7.631C3.7985 9.578 3.1135 10 2.178 10C1.573 10 1.0755 9.79 0.71 9.409L0.626 9.317C0.384 9.0305 0.2075 8.6615 0.1045 8.2225L0.071 8.0625C0.0323456 7.84982 0.00961585 7.63456 0.003 7.4185L0 7.234C0.001 6.8615 0.03 6.489 0.087 6.119L0.137 5.8325C0.286 5.0675 0.551 4.3535 0.905 3.754L1.0095 3.584C1.598 2.669 2.404 2.0575 3.317 2.004L3.4485 2ZM3.432 3.3075L3.3315 3.3125C2.9165 3.354 2.5285 3.649 2.2055 4.101L2.1365 4.2005L2.1315 4.2095C1.7965 4.718 1.539 5.3985 1.4035 6.132L1.4015 6.143C1.33323 6.5148 1.29859 6.89199 1.298 7.27L1.299 7.364C1.301 7.454 1.3075 7.544 1.319 7.634L1.3405 7.7795C1.3865 8.031 1.469 8.2335 1.5835 8.3835L1.642 8.452C1.7935 8.6135 1.991 8.698 2.227 8.698C2.777 8.698 3.125 8.36 4.075 6.8775L5.1625 5.1775L5.3895 4.827L5.32 4.728C4.555 3.65 4.042 3.3075 3.432 3.3075ZM8.53 3.0315L8.442 3.035C8.1245 3.059 7.8305 3.2145 7.532 3.5015L7.434 3.6005C7.2145 3.8315 6.9905 4.1325 6.7505 4.504L6.8835 4.703C6.9735 4.84 7.0645 4.983 7.1585 5.132L7.305 5.3695L8.003 6.537L8.3505 7.094C8.642 7.557 8.8655 7.894 9.0545 8.135L9.161 8.266C9.302 8.429 9.4255 8.536 9.5495 8.6025L9.6005 8.6275C9.714 8.6775 9.829 8.6965 9.9595 8.6965C10.0475 8.6975 10.1345 8.685 10.2185 8.66C10.3875 8.608 10.5235 8.5 10.625 8.3415L10.6725 8.26L10.711 8.179C10.808 7.9495 10.856 7.649 10.856 7.2865L10.853 7.062C10.813 5.6265 10.384 4.376 9.753 3.663L9.665 3.5685C9.33 3.227 8.943 3.0315 8.53 3.0315Z"
fill="currentColor"
/>
</svg>
);
}
export function DeepseekIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_20374_57805)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M23.7479 4.48176C23.4939 4.35776 23.384 4.59476 23.236 4.71576C23.185 4.75476 23.142 4.80576 23.099 4.85176C22.727 5.24876 22.293 5.50876 21.726 5.47776C20.897 5.43176 20.189 5.69176 19.563 6.32576C19.43 5.54376 18.988 5.07776 18.316 4.77776C17.964 4.62176 17.608 4.46676 17.361 4.12776C17.189 3.88676 17.142 3.61776 17.056 3.35376C17.001 3.19376 16.946 3.03076 16.763 3.00376C16.563 2.97276 16.4849 3.13976 16.4069 3.27976C16.094 3.85176 15.9729 4.48176 15.9849 5.11976C16.0119 6.55576 16.618 7.69976 17.823 8.51276C17.96 8.60576 17.995 8.69976 17.952 8.83576C17.87 9.11576 17.772 9.38776 17.686 9.66876C17.631 9.84776 17.549 9.88576 17.357 9.80876C16.7082 9.52995 16.1189 9.12939 15.6209 8.62876C14.7639 7.80076 13.9899 6.88676 13.0239 6.17076C12.8001 6.00537 12.5703 5.84827 12.3349 5.69976C11.3499 4.74276 12.4649 3.95676 12.7229 3.86376C12.9929 3.76576 12.8159 3.43176 11.9439 3.43576C11.0719 3.43976 10.2739 3.73076 9.25695 4.11976C9.10582 4.17767 8.95033 4.22348 8.79195 4.25676C7.84158 4.07769 6.8696 4.0433 5.90895 4.15476C4.02395 4.36476 2.51895 5.25676 1.41195 6.77776C0.0819496 8.60576 -0.23105 10.6838 0.15195 12.8498C0.55495 15.1338 1.72095 17.0248 3.51195 18.5028C5.36995 20.0358 7.50895 20.7868 9.94995 20.6428C11.4319 20.5578 13.0829 20.3588 14.9439 18.7828C15.4139 19.0168 15.906 19.1098 16.724 19.1798C17.354 19.2388 17.96 19.1498 18.429 19.0518C19.164 18.8958 19.1129 18.2148 18.8479 18.0908C16.693 17.0868 17.166 17.4958 16.735 17.1648C17.831 15.8688 19.481 14.5228 20.127 10.1618C20.177 9.81476 20.134 9.59676 20.127 9.31676C20.123 9.14676 20.162 9.07976 20.357 9.06076C20.898 9.00463 21.4228 8.84327 21.902 8.58576C23.298 7.82276 23.862 6.57076 23.995 5.06876C24.015 4.83876 23.9909 4.60276 23.7479 4.48176ZM11.5809 17.9998C9.49195 16.3578 8.47895 15.8168 8.06095 15.8398C7.66895 15.8638 7.73995 16.3108 7.82595 16.6028C7.91595 16.8908 8.03295 17.0888 8.19695 17.3418C8.31095 17.5088 8.38895 17.7578 8.08395 17.9448C7.41095 18.3608 6.24195 17.8048 6.18695 17.7778C4.82595 16.9758 3.68695 15.9178 2.88595 14.4708C2.11195 13.0778 1.66195 11.5838 1.58795 9.98876C1.56795 9.60276 1.68095 9.46676 2.06495 9.39676C2.56906 9.30029 3.08558 9.28711 3.59395 9.35776C5.72595 9.66976 7.53995 10.6228 9.06195 12.1318C9.92995 12.9918 10.5869 14.0188 11.2639 15.0228C11.9839 16.0888 12.7579 17.1048 13.7439 17.9368C14.0919 18.2288 14.3689 18.4508 14.6349 18.6138C13.8329 18.7038 12.4949 18.7238 11.5809 17.9998ZM12.5809 11.5598C12.5808 11.5101 12.5927 11.4611 12.6157 11.4171C12.6387 11.373 12.672 11.3353 12.7129 11.307C12.7538 11.2787 12.8009 11.2609 12.8502 11.2549C12.8995 11.2489 12.9495 11.2551 12.9959 11.2728C13.0551 11.294 13.1062 11.3331 13.142 11.3848C13.1779 11.4364 13.1967 11.4979 13.1959 11.5608C13.1961 11.6014 13.1881 11.6416 13.1726 11.6791C13.157 11.7166 13.1341 11.7506 13.1053 11.7792C13.0764 11.8078 13.0422 11.8303 13.0045 11.8455C12.9669 11.8607 12.9266 11.8683 12.8859 11.8678C12.8457 11.8679 12.8057 11.86 12.7685 11.8445C12.7313 11.829 12.6976 11.8063 12.6693 11.7776C12.641 11.7489 12.6186 11.7149 12.6037 11.6775C12.5887 11.6401 12.5803 11.6 12.5809 11.5598ZM15.6909 13.1558C15.4909 13.2368 15.2919 13.3068 15.1009 13.3158C14.8136 13.3258 14.5316 13.236 14.3029 13.0618C14.0289 12.8318 13.8329 12.7038 13.7509 12.3038C13.7227 12.1083 13.7281 11.9094 13.7669 11.7158C13.8369 11.3888 13.7589 11.1788 13.5279 10.9888C13.3409 10.8328 13.1019 10.7898 12.8399 10.7898C12.7502 10.7845 12.6631 10.7578 12.5859 10.7118C12.4759 10.6578 12.3859 10.5218 12.4719 10.3538C12.4999 10.2998 12.6319 10.1678 12.6639 10.1438C13.0199 9.94176 13.4309 10.0078 13.8099 10.1598C14.1619 10.3038 14.4279 10.5678 14.8109 10.9418C15.2019 11.3928 15.2729 11.5178 15.4959 11.8558C15.6719 12.1208 15.8319 12.3928 15.9409 12.7038C16.0079 12.8988 15.9219 13.0578 15.6909 13.1558Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_20374_57805">
<rect width="24" height="24" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
export function XAIIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17.7329 8.44672L18.0784 22.0139H20.8452L21.1911 3.50781L17.7329 8.44672Z"
fill="currentColor"
/>
<path d="M21.1911 2H16.9692L10.3442 11.4621L12.4552 14.4768L21.1911 2Z" fill="currentColor" />
<path
d="M2.95508 22.0136H7.17691L9.28824 18.9989L7.17691 15.9839L2.95508 22.0136Z"
fill="currentColor"
/>
<path
d="M2.95508 8.44629L12.4546 22.0134H16.6764L7.17691 8.44629H2.95508Z"
fill="currentColor"
/>
</svg>
);
}
export function PerplexityIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.4875 2V8.06H20.75V16.6833H18.3042V22L12.44 16.8383V21.9592H11.5308V16.8325L5.66 22V16.6125H3.25V7.99H5.65333V2L11.5308 7.41167V2.15833H12.4392V7.56667L18.4875 2ZM12.44 9.53667V15.6358L17.395 19.9975V14.0333L12.44 9.53667ZM11.5242 9.47L6.56917 13.9683V19.9975L11.5242 15.6358V9.47083V9.47ZM18.3042 15.7867H19.8408V8.9575H13.2167L18.3042 13.5742V15.7867ZM10.8192 8.88667H4.15833V15.7158H5.65833V13.5692L10.8183 8.88583L10.8192 8.88667ZM6.5625 4.06333V7.98833H10.825L6.5625 4.06333ZM17.5783 4.06333L13.3158 7.98833H17.5783V4.06333Z"
fill="currentColor"
/>
</svg>
);
}
export function CerebrasIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.6535 20.6834C10.4382 20.6834 9.28717 20.4401 8.23625 20.0036C6.66345 19.3453 5.31944 18.2433 4.36862 16.8551C3.4178 15.4669 2.86732 13.7997 2.86732 11.9964C2.86732 10.7943 3.11039 9.65655 3.56078 8.61182C4.22564 7.0519 5.3409 5.72809 6.7421 4.7907C8.1433 3.85331 9.83047 3.30948 11.6535 3.30948V2C10.2594 2 8.92972 2.27907 7.71437 2.78712C5.8985 3.54562 4.35432 4.81217 3.26767 6.40788C2.17386 8.00356 1.5376 9.92844 1.5376 11.9964C1.5376 13.3774 1.82356 14.6941 2.33114 15.8891C3.09609 17.6851 4.38291 19.2093 5.99144 20.2898C7.60713 21.3703 9.55167 22 11.6463 22V20.6834H11.6535Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.14949 17.3272C6.32735 16.6403 5.71253 15.8102 5.2979 14.9014C4.88323 13.9927 4.66877 13.0124 4.66877 12.0249C4.66877 11.2378 4.8046 10.4506 5.06911 9.69931C5.34079 8.94794 5.74113 8.23241 6.29159 7.58122C6.9779 6.7655 7.81433 6.15012 8.72225 5.73508C9.63021 5.32005 10.6239 5.11254 11.6105 5.11254C12.3969 5.11254 13.1904 5.24849 13.9411 5.51325C14.6989 5.78516 15.4138 6.18588 16.0643 6.7297L16.9151 5.72077C16.143 5.07676 15.2851 4.59018 14.3843 4.27533C13.4835 3.95332 12.547 3.7959 11.6105 3.7959C10.4309 3.7959 9.25846 4.04634 8.17178 4.54009C7.08514 5.03382 6.09141 5.77087 5.27643 6.73687C4.62587 7.50967 4.14689 8.36119 3.82518 9.25563C3.50347 10.1501 3.34619 11.0875 3.34619 12.0249C3.34619 13.1984 3.59641 14.3719 4.08969 15.4524C4.58298 16.5329 5.32649 17.5276 6.29876 18.3362L7.14949 17.3272Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.18714 16.4758C8.32211 16.0179 7.64298 15.3524 7.17829 14.5724C6.7136 13.7925 6.47052 12.8909 6.47052 11.9821C6.47052 11.1807 6.65641 10.3721 7.06388 9.62074C7.52144 8.75492 8.19345 8.08228 8.97983 7.6243C9.76624 7.1592 10.667 6.9159 11.5821 6.9159C12.3828 6.9159 13.1978 7.10194 13.9556 7.50269L14.5704 6.33631C13.6196 5.83541 12.5901 5.59212 11.5749 5.59927C10.424 5.59927 9.28725 5.90697 8.30069 6.48655C7.31412 7.06618 6.46339 7.92487 5.89146 9.00535C5.39101 9.95706 5.14795 10.9803 5.14795 11.9821C5.14795 13.127 5.45536 14.2576 6.04159 15.2379C6.62782 16.2254 7.48568 17.0626 8.57236 17.635L9.18714 16.4758Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.6608 15.2165C11.2104 15.2165 10.7815 15.1235 10.3955 14.9589C9.80924 14.7156 9.31596 14.3005 8.96564 13.7782C8.61536 13.2558 8.40804 12.6333 8.40804 11.9606C8.40804 11.5098 8.50095 11.0805 8.66537 10.6941C8.90845 10.1145 9.32309 9.61359 9.84496 9.26297C10.3669 8.91235 10.9888 8.70484 11.6608 8.70484V7.38818C11.0317 7.38818 10.4312 7.517 9.88072 7.74597C9.05858 8.09659 8.36511 8.66905 7.87183 9.39892C7.37142 10.136 7.08545 11.0233 7.08545 11.9678C7.08545 12.5975 7.21412 13.1986 7.4429 13.7496C7.79322 14.5725 8.37228 15.2666 9.10147 15.7603C9.83067 16.2469 10.71 16.5331 11.6608 16.5331V15.2165Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.7332 10.9234C12.5831 10.766 12.4187 10.6372 12.2542 10.5442C12.0898 10.4511 11.9183 10.401 11.7395 10.401C11.4965 10.401 11.2891 10.444 11.0961 10.5299C10.9102 10.6157 10.7458 10.7302 10.61 10.8805C10.4741 11.0236 10.374 11.1953 10.3026 11.3814C10.2311 11.5674 10.2025 11.7678 10.2025 11.9681C10.2025 12.1685 10.2382 12.3689 10.3026 12.5549C10.374 12.7409 10.4741 12.9127 10.61 13.0558C10.7458 13.1989 10.9031 13.3206 11.0961 13.4064C11.282 13.4923 11.4965 13.5352 11.7395 13.5352C11.9397 13.5352 12.1327 13.4923 12.3043 13.4136C12.4759 13.3277 12.626 13.2061 12.7475 13.0486L13.6197 13.986C13.491 14.1148 13.3409 14.2293 13.1693 14.3223C12.9978 14.4154 12.8262 14.4941 12.6546 14.5513C12.483 14.6086 12.3114 14.6515 12.1542 14.673C11.9969 14.7016 11.8539 14.7087 11.7395 14.7087C11.3463 14.7087 10.9746 14.6443 10.6314 14.5155C10.2811 14.3868 9.98084 14.2007 9.73064 13.9574C9.47326 13.7213 9.27311 13.4279 9.12298 13.0916C8.97285 12.7553 8.90137 12.376 8.90137 11.9681C8.90137 11.5531 8.97285 11.181 9.12298 10.8447C9.27311 10.5084 9.47326 10.2222 9.73064 9.97887C9.98801 9.74274 10.2883 9.55669 10.6314 9.42075C10.9817 9.29193 11.3535 9.22754 11.7395 9.22754C12.0755 9.22754 12.4115 9.29193 12.7475 9.42075C13.0835 9.54953 13.3838 9.7499 13.634 10.0218L12.7332 10.9234Z"
fill="currentColor"
/>
</svg>
);
}
export function MistralIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M7.28516 3.74658H4.1416V6.8809H7.28516V3.74658Z" />
<path d="M19.8579 3.74658H16.7144V6.8809H19.8579V3.74658Z" />
<path d="M10.4277 6.88086H4.1416V10.0152H10.4277V6.88086Z" />
<path d="M19.8588 6.88086H13.5728V10.0152H19.8588V6.88086Z" />
<path d="M19.8564 10.0137H4.1416V13.148H19.8564V10.0137Z" />
<path d="M7.28516 13.1484H4.1416V16.2828H7.28516V13.1484Z" />
<path d="M13.5723 13.1484H10.4287V16.2828H13.5723V13.1484Z" />
<path d="M19.8579 13.1484H16.7144V16.2828H19.8579V13.1484Z" />
<path d="M10.4286 16.2812H1V19.4157H10.4286V16.2812Z" />
<path d="M23.0024 16.2812H13.5728V19.4157H23.0024V16.2812Z" />
</svg>
);
}
export function AzureIcon({ className }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.05 4.24L6.56 18.05l2.77-.46 1.89-4.55 4.2 5.19.03-.01 2.99.53L13.05 4.24zm-4.1 7.51L2.49 20.21h5.23l1.23-3.04v-5.42z" />
</svg>
);
}
@@ -0,0 +1,12 @@
export function AnthropicLogoIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg>
);
}
@@ -4,11 +4,13 @@ import {
BookOpenIcon,
ChatBubbleLeftRightIcon,
ClockIcon,
DocumentTextIcon,
PlusIcon,
QuestionMarkCircleIcon,
RectangleGroupIcon,
RectangleStackIcon,
ServerStackIcon,
SparklesIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { useLocation } from "react-use";
@@ -686,3 +688,55 @@ function DeploymentOnboardingSteps() {
</PackageManagerProvider>
);
}
export function PromptsNone() {
return (
<InfoPanel
title="Define your first prompt"
icon={SparklesIcon}
iconClassName="text-purple-500"
panelClassName="max-w-lg"
accessory={
<LinkButton to={docsPath("prompt-management")} variant="docs/small" LeadingIcon={BookOpenIcon}>
Prompt docs
</LinkButton>
}
>
<Paragraph spacing variant="small">
Managed prompts let you define AI prompts in code with typesafe variables, then edit and
version them from the dashboard without redeploying.
</Paragraph>
<Paragraph spacing variant="small">
Add a prompt to your project using <InlineCode variant="small">prompts.define()</InlineCode>:
</Paragraph>
<div className="rounded border border-grid-dimmed bg-charcoal-900 p-3">
<pre className="text-xs leading-relaxed text-text-dimmed">
<span className="text-purple-400">import</span>
{" { prompts } "}
<span className="text-purple-400">from</span>
{' "@trigger.dev/sdk";\n'}
<span className="text-purple-400">import</span>
{" { z } "}
<span className="text-purple-400">from</span>
{' "zod";\n\n'}
<span className="text-purple-400">export const</span>
{" myPrompt = "}
<span className="text-blue-400">prompts.define</span>
{"({\n"}
{" id: "}
<span className="text-green-400">"my-prompt"</span>
{",\n"}
{" variables: z.object({\n"}
{" name: z.string(),\n"}
{" }),\n"}
{" content: "}
<span className="text-green-400">{"`Hello {{name}}!`"}</span>
{",\n"});</pre>
</div>
<Paragraph variant="small" className="mt-2">
Deploy your project and your prompts will appear here with version history and a live
editor.
</Paragraph>
</InfoPanel>
);
}
@@ -228,6 +228,18 @@ export function BulkActionFilterSummary({
/>
);
}
case "errorId": {
return (
<AppliedFilter
variant="minimal/medium"
key={key}
label={"Error ID"}
icon={filterIcon(key)}
value={value}
removable={false}
/>
);
}
default: {
assertNever(typedKey);
}
@@ -1,6 +1,9 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { BarChart3, LineChart } from "lucide-react";
import { memo, useMemo } from "react";
import { createValueFormatter } from "~/utils/columnFormat";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
@@ -855,8 +858,24 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, [isDateBased, timeGranularity]);
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Resolve the Y-axis column format for formatting
const yAxisFormat = useMemo(() => {
if (yAxisColumns.length === 0) return undefined;
const col = columns.find((c) => c.name === yAxisColumns[0]);
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
}, [yAxisColumns, columns]);
// Create dynamic Y-axis formatter based on data range and format
const yAxisFormatter = useMemo(
() => createYAxisFormatter(data, series, yAxisFormat),
[data, series, yAxisFormat]
);
// Create value formatter for tooltips and legend based on column format
const tooltipValueFormatter = useMemo(
() => createValueFormatter(yAxisFormat),
[yAxisFormat]
);
// Check if the group-by column has a runStatus customRenderType
const groupByIsRunStatus = useMemo(() => {
@@ -870,13 +889,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
const cfg: ChartConfig = {};
sortedSeries.forEach((s, i) => {
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
const originalIndex = config.yAxisColumns.indexOf(s);
const colorIndex = originalIndex >= 0 ? originalIndex : i;
cfg[s] = {
label: s,
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
};
});
return cfg;
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -1081,6 +1102,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
@@ -1093,6 +1115,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
/>
</Chart.Root>
);
@@ -1110,6 +1133,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
@@ -1122,6 +1146,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
yAxisProps={yAxisProps}
stacked={stacked && visibleSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
@@ -1129,9 +1154,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
});
/**
* Creates a Y-axis value formatter based on the data range
* Creates a Y-axis value formatter based on the data range and optional format hint
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
function createYAxisFormatter(
data: Record<string, unknown>[],
series: string[],
format?: ColumnFormatType
) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
@@ -1148,6 +1177,56 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
const range = maxVal - minVal;
// Format-aware formatters
if (format === "bytes" || format === "decimalBytes") {
const divisor = format === "bytes" ? 1024 : 1000;
const units =
format === "bytes"
? ["B", "KiB", "MiB", "GiB", "TiB"]
: ["B", "KB", "MB", "GB", "TB"];
return (value: number): string => {
if (value === 0) return "0 B";
// Use consistent unit for all ticks based on max value
const i = Math.min(
Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))),
units.length - 1
);
const scaled = value / Math.pow(divisor, i);
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
};
}
if (format === "percent") {
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
}
if (format === "duration") {
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
}
if (format === "durationSeconds") {
return (value: number): string =>
formatDurationMilliseconds(value * 1000, { style: "short" });
}
if (format === "durationNs") {
return (value: number): string =>
formatDurationMilliseconds(value / 1_000_000, { style: "short" });
}
if (format === "costInDollars" || format === "cost") {
return (value: number): string => {
const dollars = format === "cost" ? value / 100 : value;
if (dollars === 0) return "$0";
if (Math.abs(dollars) >= 1000) return `$${(dollars / 1000).toFixed(1)}K`;
if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`;
if (Math.abs(dollars) >= 0.01) return `$${dollars.toFixed(4)}`;
if (Math.abs(dollars) >= 0.0001) return `$${dollars.toFixed(6)}`;
return formatCurrencyAccurate(dollars);
};
}
// Default formatter
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
@@ -35,6 +35,7 @@ import { useCopy } from "~/hooks/useCopy";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
@@ -66,9 +67,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
if (value === null) return "NULL";
if (value === undefined) return "";
// Handle custom render types
if (column.customRenderType) {
switch (column.customRenderType) {
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
const formatType = column.format ?? column.customRenderType;
if (formatType) {
switch (formatType) {
case "duration":
if (typeof value === "number") {
return formatDurationMilliseconds(value, { style: "short" });
@@ -79,6 +81,11 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return formatDurationMilliseconds(value * 1000, { style: "short" });
}
break;
case "durationNs":
if (typeof value === "number") {
return formatDurationMilliseconds(value / 1_000_000, { style: "short" });
}
break;
case "cost":
if (typeof value === "number") {
return formatCurrencyAccurate(value / 100);
@@ -95,6 +102,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return value;
}
break;
case "bytes":
if (typeof value === "number") {
return formatBytes(value);
}
break;
case "decimalBytes":
if (typeof value === "number") {
return formatDecimalBytes(value);
}
break;
case "percent":
if (typeof value === "number") {
return `${value.toFixed(2)}%`;
}
break;
case "quantity":
if (typeof value === "number") {
return formatQuantity(value);
}
break;
}
}
@@ -111,6 +138,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
});
} catch {
return String(value);
@@ -222,6 +250,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
if (value === null) return 4; // "NULL"
if (value === undefined) return 9; // "UNDEFINED"
// Handle format hint types - estimate their rendered width
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes") {
// e.g., "1.50 GiB" or "256.00 MB"
return 12;
}
if (fmt === "percent") {
// e.g., "45.23%"
return 8;
}
if (fmt === "quantity") {
// e.g., "1.50M"
return 8;
}
// Handle custom render types - estimate their rendered width
if (column.customRenderType) {
switch (column.customRenderType) {
@@ -244,6 +287,12 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
return formatted.length;
}
return 10;
case "durationNs":
if (typeof value === "number") {
const formatted = formatDurationMilliseconds(value / 1_000_000, { style: "short" });
return formatted.length;
}
return 10;
case "cost":
case "costInDollars":
// Currency format: "$1,234.56"
@@ -263,6 +312,8 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
return typeof value === "string" ? Math.min(value.length, 20) : 12;
case "queue":
return typeof value === "string" ? Math.min(value.length, 25) : 15;
case "deploymentId":
return typeof value === "string" ? Math.min(value.length, 25) : 20;
}
}
@@ -394,6 +445,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
) {
return true;
}
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
return true;
}
return isNumericType(column.type);
}
@@ -405,10 +460,12 @@ function CellValueWrapper({
value,
column,
prettyFormatting,
row,
}: {
value: unknown;
column: OutputColumnMetadata;
prettyFormatting: boolean;
row?: Record<string, unknown>;
}) {
const [hovered, setHovered] = useState(false);
@@ -423,6 +480,7 @@ function CellValueWrapper({
column={column}
prettyFormatting={prettyFormatting}
hovered={hovered}
row={row}
/>
</span>
);
@@ -436,11 +494,13 @@ function CellValue({
column,
prettyFormatting = true,
hovered = false,
row,
}: {
value: unknown;
column: OutputColumnMetadata;
prettyFormatting?: boolean;
hovered?: boolean;
row?: Record<string, unknown>;
}) {
// Plain text mode - render everything as monospace text with truncation
if (!prettyFormatting) {
@@ -476,17 +536,51 @@ function CellValue({
return <pre className="text-text-dimmed">UNDEFINED</pre>;
}
// Check format hint for new format types (from prettyFormat())
if (column.format && !column.customRenderType) {
switch (column.format) {
case "bytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatBytes(value)}</span>;
}
break;
case "decimalBytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
}
break;
case "percent":
if (typeof value === "number") {
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
}
break;
case "quantity":
if (typeof value === "number") {
return <span className="tabular-nums">{formatQuantity(value)}</span>;
}
break;
}
}
// First check customRenderType for special rendering
if (column.customRenderType) {
switch (column.customRenderType) {
case "runId": {
if (typeof value === "string") {
const spanId = row?.["span_id"];
const runPath = v3RunPathFromFriendlyId(value);
const href = typeof spanId === "string" && spanId
? `${runPath}?span=${spanId}`
: runPath;
const tooltip = typeof spanId === "string" && spanId
? "Jump to span"
: "Jump to run";
return (
<SimpleTooltip
content="Jump to run"
content={tooltip}
disableHoverableContent
hidden={!hovered}
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
button={<TextLink to={href}>{value}</TextLink>}
/>
);
}
@@ -528,6 +622,15 @@ function CellValue({
);
}
return <span>{String(value)}</span>;
case "durationNs":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value / 1_000_000, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "cost":
if (typeof value === "number") {
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
@@ -577,6 +680,19 @@ function CellValue({
}
return <span>{String(value)}</span>;
}
case "deploymentId": {
if (typeof value === "string" && value.startsWith("deployment_")) {
return (
<SimpleTooltip
content="Jump to deployment"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={`/deployments/${value}`}>{value}</TextLink>}
/>
);
}
return <span>{String(value)}</span>;
}
}
}
@@ -585,7 +701,7 @@ function CellValue({
if (isDateTimeType(type)) {
if (typeof value === "string") {
return <DateTimeAccurate date={value} showTooltip={hovered} />;
return <DateTimeAccurate date={value} showTooltip={hovered} timeZone="UTC" />;
}
return <span>{String(value)}</span>;
}
@@ -907,6 +1023,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
prettyFormatting = true,
sorting: defaultSorting = [],
showHeaderOnEmpty = false,
hiddenColumns,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
@@ -914,6 +1031,8 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
sorting?: SortingState;
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
showHeaderOnEmpty?: boolean;
/** Column names to hide from display but keep in row data (useful for linking) */
hiddenColumns?: string[];
}) {
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -927,9 +1046,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Create TanStack Table column definitions from OutputColumnMetadata
// Calculate column widths based on content
const visibleColumns = useMemo(
() => hiddenColumns?.length ? columns.filter((col) => !hiddenColumns.includes(col.name)) : columns,
[columns, hiddenColumns]
);
const columnDefs = useMemo<ColumnDef<RowData, unknown>[]>(
() =>
columns.map((col) => ({
visibleColumns.map((col) => ({
id: col.name,
accessorKey: col.name,
header: () => col.name,
@@ -938,6 +1061,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
value={info.getValue()}
column={col}
prettyFormatting={prettyFormatting}
row={info.row.original}
/>
),
meta: {
@@ -947,7 +1071,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
size: calculateColumnWidth(col.name, rows, col),
filterFn: fuzzyFilter,
})),
[columns, rows, prettyFormatting]
[visibleColumns, rows, prettyFormatting]
);
// Initialize TanStack Table
@@ -0,0 +1,125 @@
import type { ViewUpdate } from "@codemirror/view";
import { EditorView, lineNumbers } from "@codemirror/view";
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
import { useCodeMirror } from "@uiw/react-codemirror";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { getEditorSetup } from "./codeMirrorSetup";
import { darkTheme } from "./codeMirrorTheme";
export interface TextEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
defaultValue?: string;
readOnly?: boolean;
onChange?: (value: string) => void;
onUpdate?: (update: ViewUpdate) => void;
showCopyButton?: boolean;
additionalActions?: React.ReactNode;
}
export function TextEditor(opts: TextEditorProps) {
const {
defaultValue = "",
readOnly = false,
onChange,
onUpdate,
autoFocus,
showCopyButton = true,
additionalActions,
} = opts;
// Don't use default line numbers from setup — add our own with proper sizing
const extensions = getEditorSetup(false);
extensions.push(EditorView.lineWrapping);
extensions.push(
lineNumbers({
formatNumber: (n) => String(n),
})
);
extensions.push(
EditorView.theme({
".cm-lineNumbers": {
minWidth: "40px",
},
})
);
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: false,
onChange,
onUpdate,
};
const { setContainer, view } = useCodeMirror(settings);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (editor.current) {
setContainer(editor.current);
}
}, [setContainer]);
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 copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [view]);
const showToolbar = showCopyButton || additionalActions;
return (
<div
className={cn(
"grid",
showToolbar ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
opts.className
)}
>
{showToolbar && (
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<div className="flex items-center">{additionalActions}</div>
<div className="flex items-center gap-2">
{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>
)}
<div className="min-h-0 min-w-0 overflow-auto" ref={editor} />
</div>
);
}
@@ -2,6 +2,7 @@ import { Switch } from "~/components/primitives/Switch";
import { Label } from "~/components/primitives/Label";
import { Hint } from "~/components/primitives/Hint";
import { TextLink } from "~/components/primitives/TextLink";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import {
EnvironmentIcon,
environmentFullTitle,
@@ -18,6 +19,8 @@ type BuildSettingsFieldsProps = {
atomicBuilds: EnvSlug[];
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
envVarsConfigLink?: string;
/** Slugs that should be forced off and disabled, with tooltip reason. */
disabledEnvSlugs?: Partial<Record<EnvSlug, string>>;
};
export function BuildSettingsFields({
@@ -29,42 +32,51 @@ export function BuildSettingsFields({
atomicBuilds,
onAtomicBuildsChange,
envVarsConfigLink,
disabledEnvSlugs,
}: BuildSettingsFieldsProps) {
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s));
return (
<>
{/* Pull env vars before build */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Pull env vars before build</Label>
<Hint>
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 0 &&
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
}}
/>
)}
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
}}
/>
)}
<Hint className="pr-6">
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
return (
<div key={slug} className="flex items-center justify-between">
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const row = (
<div
key={slug}
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
@@ -73,7 +85,8 @@ export function BuildSettingsFields({
</div>
<Switch
variant="small"
checked={pullEnvVarsBeforeBuild.includes(slug)}
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
disabled={disabled}
onCheckedChange={(checked) => {
onPullEnvVarsChange(
checked
@@ -84,49 +97,57 @@ export function BuildSettingsFields({
/>
</div>
);
if (disabled && disabledReason) {
return (
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
);
}
return row;
})}
</div>
</div>
{/* Discover new env vars */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Discover new env vars</Label>
<Hint>
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
enabledSlugs.length > 0 &&
enabledSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
enabledSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
</div>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
<Hint className="pr-6">
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const disabled = isSlugDisabled(slug);
const disabledReason = disabledEnvSlugs?.[slug];
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
return (
const row = (
<div
key={slug}
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
@@ -136,8 +157,8 @@ export function BuildSettingsFields({
</div>
<Switch
variant="small"
checked={discoverEnvVars.includes(slug)}
disabled={isPullDisabled}
checked={disabled ? false : discoverEnvVars.includes(slug)}
disabled={disabled || isPullDisabled}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
@@ -148,6 +169,12 @@ export function BuildSettingsFields({
/>
</div>
);
if (disabled && disabledReason) {
return (
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
);
}
return row;
})}
</div>
</div>
@@ -155,13 +182,7 @@ export function BuildSettingsFields({
{/* Atomic deployments */}
<div>
<div className="flex items-center justify-between">
<div>
<Label>Atomic deployments</Label>
<Hint>
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment.
</Hint>
</div>
<Label>Atomic deployments</Label>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
@@ -170,6 +191,16 @@ export function BuildSettingsFields({
}}
/>
</div>
<Hint className="pr-6">
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
Production Domains" option in your Vercel project settings to perform staged
deployments.{" "}
<TextLink href="https://trigger.dev/docs/vercel-integration#atomic-deployments" target="_blank">
Learn more
</TextLink>
.
</Hint>
</div>
</>
);
@@ -0,0 +1,22 @@
import { VercelLogo } from "./VercelLogo";
import { LinkButton } from "~/components/primitives/Buttons";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
return (
<SimpleTooltip
button={
<LinkButton
variant="minimal/small"
LeadingIcon={<VercelLogo className="size-3.5" />}
iconSpacing="gap-x-1"
to={vercelDeploymentUrl}
className="pl-1"
>
Vercel
</LinkButton>
}
content="View on Vercel"
/>
);
}
@@ -44,10 +44,11 @@ import {
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
import { TextLink } from "../primitives/TextLink";
function safeRedirectUrl(url: string): string | null {
try {
@@ -102,6 +103,7 @@ export function VercelOnboardingModal({
hasOrgIntegration,
nextUrl,
onDataReload,
vercelManageAccessUrl,
}: {
isOpen: boolean;
onClose: () => void;
@@ -114,6 +116,7 @@ export function VercelOnboardingModal({
hasOrgIntegration: boolean;
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
vercelManageAccessUrl?: string;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
@@ -122,7 +125,8 @@ export function VercelOnboardingModal({
const completeOnboardingFetcher = useFetcher();
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
const [searchParams] = useSearchParams();
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
const origin = searchParams.get("origin");
const fromMarketplaceContext = origin === "marketplace";
const availableProjects = onboardingData?.availableProjects || [];
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
@@ -221,9 +225,10 @@ export function VercelOnboardingModal({
() => availableEnvSlugsForOnboardingBuildSettings
);
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once)
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true
// AND a custom Vercel environment is mapped (once)
useEffect(() => {
if (hasStagingEnvironment && !hasSyncedStagingRef.current) {
if (hasStagingEnvironment && vercelStagingEnvironment && !hasSyncedStagingRef.current) {
hasSyncedStagingRef.current = true;
setPullEnvVarsBeforeBuild((prev) => {
if (!prev.includes("stg")) {
@@ -238,7 +243,15 @@ export function VercelOnboardingModal({
return prev;
});
}
}, [hasStagingEnvironment]);
}, [hasStagingEnvironment, vercelStagingEnvironment]);
// Strip "stg" from build settings when the staging environment mapping is cleared
useEffect(() => {
if (!vercelStagingEnvironment) {
setPullEnvVarsBeforeBuild((prev) => prev.filter((s) => s !== "stg"));
setDiscoverEnvVars((prev) => prev.filter((s) => s !== "stg"));
}
}, [vercelStagingEnvironment]);
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once)
useEffect(() => {
@@ -528,6 +541,9 @@ export function VercelOnboardingModal({
formData.append("atomicBuilds", JSON.stringify(atomicBuilds));
formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars));
formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping));
if (fromMarketplaceContext) {
formData.append("origin", "marketplace");
}
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
formData.append("next", nextUrl);
}
@@ -543,8 +559,15 @@ export function VercelOnboardingModal({
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
capture("vercel onboarding github step viewed", {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: "github-connection",
organization_slug: organizationSlug,
project_slug: projectSlug,
github_app_installed: gitHubAppInstallations.length > 0,
});
}
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -639,7 +662,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
@@ -660,6 +683,11 @@ export function VercelOnboardingModal({
const showBuildSettings = state === "build-settings";
const showGitHubConnection = state === "github-connection";
const disabledEnvSlugsForBuildSettings =
hasStagingEnvironment && !vercelStagingEnvironment
? ({ stg: "Map a custom Vercel environment to Staging to enable this" } as Partial<Record<EnvSlug, string>>)
: undefined;
return (
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
@@ -669,7 +697,7 @@ export function VercelOnboardingModal({
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2">
<VercelLogo className="size-5" />
@@ -721,20 +749,31 @@ export function VercelOnboardingModal({
)}
<Hint>
Once connected, your <code className="text-xs">TRIGGER_SECRET_KEY</code> will be
Once connected, your <code className="text-xs rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code> will be
automatically synced to Vercel for each environment.
</Hint>
<FormButtons
confirmButton={
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
<div className="flex items-center gap-2">
{vercelManageAccessUrl && !origin && (
<LinkButton
to={vercelManageAccessUrl}
variant="tertiary/medium"
target="_self"
>
Manage access
</LinkButton>
)}
<Button
variant="primary/medium"
onClick={handleProjectSelection}
disabled={!selectedVercelProject || fetcher.state !== "idle"}
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
>
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
</Button>
</div>
}
cancelButton={
<Button
@@ -754,6 +793,10 @@ export function VercelOnboardingModal({
<Paragraph className="text-sm">
Select which custom Vercel environment should map to Trigger.dev's Staging
environment. Production and Preview environments are mapped automatically.
If you skip this step, the{" "}
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
will not be installed for the staging environment in Vercel. You can configure this later in
project settings.
</Paragraph>
<Select
@@ -779,6 +822,11 @@ export function VercelOnboardingModal({
))}
</Select>
<Paragraph className="text-xs text-text-dimmed">
Make sure the staging branch in your Vercel project's Git settings matches the staging branch
configured in your GitHub integration.
</Paragraph>
<div className="flex items-center justify-between gap-2">
<Button
variant="tertiary/medium"
@@ -810,24 +858,13 @@ export function VercelOnboardingModal({
{showEnvVarSync && (
<div className="flex flex-col gap-4">
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Select which environment variables to pull from Vercel now. This is a one-time pull.
</Paragraph>
<div className="flex gap-4 text-sm">
<div className="rounded border bg-charcoal-750 px-3 py-2">
<span className="font-medium text-text-bright">{syncableEnvVars.length}</span>
<span className="text-text-dimmed"> can be pulled</span>
</div>
{secretEnvVars.length > 0 && (
<div className="rounded border bg-charcoal-750 px-3 py-2">
<span className="font-medium text-amber-400">{secretEnvVars.length}</span>
<span className="text-text-dimmed"> secret (cannot pull)</span>
</div>
)}
<div className="flex flex-col gap-1">
<Header3>Pull Environment Variables</Header3>
<Paragraph className="text-sm">
Choose which environment variables to import from Vercel. This runs as a one time pull to prefill your project with the variables it needs. Youll be able to pull again later, or enable automatic syncing before each build if you prefer.
If you are using Supabase or Neon branching, <TextLink href="https://trigger.dev/docs/vercel-integration#supabase-and-neon-database-branching" target="_blank" rel="noopener noreferrer">read the docs</TextLink> for the recommended setup.
</Paragraph>
</div>
<div className="flex items-center justify-between rounded border bg-charcoal-800 p-3">
<div>
<Label>Pull all environment variables now</Label>
@@ -1016,6 +1053,7 @@ export function VercelOnboardingModal({
onDiscoverEnvVarsChange={setDiscoverEnvVars}
atomicBuilds={atomicBuilds}
onAtomicBuildsChange={setAtomicBuilds}
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
/>
<FormButtons
@@ -1057,7 +1095,7 @@ export function VercelOnboardingModal({
</Callout>
{(() => {
const baseSettingsPath = v3ProjectSettingsPath(
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
@@ -1081,6 +1119,7 @@ export function VercelOnboardingModal({
)}
variant="secondary/medium"
LeadingIcon={OctoKitty}
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
>
Install GitHub app
</LinkButton>
@@ -1110,6 +1149,7 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github completed");
setState("completed");
const validUrl = safeRedirectUrl(nextUrl);
if (validUrl) {
@@ -1123,6 +1163,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
if (fromMarketplaceContext && nextUrl) {
const validUrl = safeRedirectUrl(nextUrl);
@@ -1141,6 +1182,7 @@ export function VercelOnboardingModal({
<Button
variant="tertiary/medium"
onClick={() => {
trackOnboarding("vercel onboarding github skipped");
setState("completed");
}}
>
@@ -186,7 +186,7 @@ function DetailsTab({
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="tertiary/small"
variant="secondary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
@@ -26,6 +26,7 @@ import {
TableRow,
type TableVariant,
} from "../primitives/Table";
import { RunsIcon } from "~/assets/icons/RunsIcon";
type LogsTableProps = {
logs: LogEntry[];
@@ -124,6 +125,7 @@ export function LogsTable({
<TableHeaderCell
className="min-w-24 whitespace-nowrap"
tooltip={<LogLevelTooltipInfo />}
disableTooltipHoverableContent
>
Level
</TableHeaderCell>
@@ -165,7 +167,7 @@ export function LogsTable({
>
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
<TruncatedCopyableValue value={log.runId} />
</TableCell>
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
@@ -185,9 +187,11 @@ export function LogsTable({
<LinkButton
to={runPath}
variant="minimal/small"
TrailingIcon={ArrowTopRightOnSquareIcon}
TrailingIcon={RunsIcon}
trailingIconClassName="text-text-bright"
className="h-[1.375rem] pl-1.5 pr-2"
>
View run
<span className="text-[0.6875rem] text-text-bright">View run</span>
</LinkButton>
}
/>
@@ -0,0 +1,159 @@
import { CubeIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
const shortcut = { key: "m" };
export type ModelOption = {
model: string;
system: string;
};
interface ModelsFilterProps {
possibleModels: ModelOption[];
}
function modelIcon(system: string, model: string): ReactNode {
// For gateway/openrouter, derive provider from model prefix
let provider = system.split(".")[0];
if (provider === "gateway" || provider === "openrouter") {
if (model.includes("/")) {
provider = model.split("/")[0].replace(/-/g, "");
}
}
// Special case: Anthropic uses a custom SVG icon
if (provider === "anthropic") {
return <AnthropicLogoIcon className="size-4 shrink-0" />;
}
const iconName = `tabler-brand-${provider}`;
if (tablerIcons.has(iconName)) {
return (
<svg className="size-4 shrink-0 stroke-[1.5]">
<use xlinkHref={`${tablerSpritePath}#${iconName}`} />
</svg>
);
}
return <CubeIcon className="size-4 shrink-0" />;
}
export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedModels = values("models");
if (selectedModels.length === 0 || selectedModels.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<SelectTrigger
icon={<CubeIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by model"
>
<span className="ml-0.5">Models</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Model"
icon={<CubeIcon className="size-4" />}
value={appliedSummary(selectedModels)}
onRemove={() => del(["models"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}
function ModelsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleModels,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleModels: ModelOption[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ models: values });
};
const filtered = useMemo(() => {
return possibleModels.filter((m) => {
return m.model?.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleModels]);
return (
<SelectProvider value={values("models")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by model..." value={searchValue} />
<SelectList>
{filtered.map((m) => (
<SelectItem key={m.model} value={m.model} icon={modelIcon(m.system, m.model)}>
{m.model}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No models found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,137 @@
import { CommandLineIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "n" };
interface OperationsFilterProps {
possibleOperations: string[];
}
/** Pretty-print an operation ID like "ai.generateText.doGenerate" → "generateText" */
function formatOperation(op: string): string {
const parts = op.split(".");
// ai.generateText.doGenerate → generateText
// ai.streamText.doStream → streamText
if (parts.length >= 2 && parts[0] === "ai") {
return parts[1];
}
return op;
}
export function OperationsFilter({ possibleOperations }: OperationsFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedOperations = values("operations");
if (selectedOperations.length === 0 || selectedOperations.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<OperationsDropdown
trigger={
<SelectTrigger
icon={<CommandLineIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by operation"
>
<span className="ml-0.5">Operations</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleOperations={possibleOperations}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<OperationsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Operation"
icon={<CommandLineIcon className="size-4" />}
value={appliedSummary(selectedOperations.map(formatOperation))}
onRemove={() => del(["operations"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleOperations={possibleOperations}
/>
)}
</FilterMenuProvider>
);
}
function OperationsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleOperations,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleOperations: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ operations: values });
};
const filtered = useMemo(() => {
const q = searchValue.toLowerCase();
return possibleOperations.filter(
(op) => op.toLowerCase().includes(q) || formatOperation(op).toLowerCase().includes(q)
);
}, [searchValue, possibleOperations]);
return (
<SelectProvider value={values("operations")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by operation..." value={searchValue} />
<SelectList>
{filtered.map((op) => (
<SelectItem key={op} value={op} icon={<CommandLineIcon className="size-4" />}>
{formatOperation(op)}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No operations found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,125 @@
import { DocumentTextIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "p" };
interface PromptsFilterProps {
possiblePrompts: string[];
}
export function PromptsFilter({ possiblePrompts }: PromptsFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedPrompts = values("prompts");
if (selectedPrompts.length === 0 || selectedPrompts.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<PromptsDropdown
trigger={
<SelectTrigger
icon={<DocumentTextIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by prompt"
>
<span className="ml-0.5">Prompts</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possiblePrompts={possiblePrompts}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<PromptsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Prompt"
icon={<DocumentTextIcon className="size-4" />}
value={appliedSummary(selectedPrompts)}
onRemove={() => del(["prompts"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possiblePrompts={possiblePrompts}
/>
)}
</FilterMenuProvider>
);
}
function PromptsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possiblePrompts,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possiblePrompts: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ prompts: values });
};
const filtered = useMemo(() => {
return possiblePrompts.filter((p) => {
return p.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possiblePrompts]);
return (
<SelectProvider value={values("prompts")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by prompt..." value={searchValue} />
<SelectList>
{filtered.map((slug) => (
<SelectItem key={slug} value={slug} icon={<DocumentTextIcon className="size-4" />}>
{slug}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No prompts found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,123 @@
import { ServerIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "v" };
interface ProvidersFilterProps {
possibleProviders: string[];
}
export function ProvidersFilter({ possibleProviders }: ProvidersFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedProviders = values("providers");
if (selectedProviders.length === 0 || selectedProviders.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ProvidersDropdown
trigger={
<SelectTrigger
icon={<ServerIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by provider"
>
<span className="ml-0.5">Providers</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleProviders={possibleProviders}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ProvidersDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Provider"
icon={<ServerIcon className="size-4" />}
value={appliedSummary(selectedProviders)}
onRemove={() => del(["providers"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleProviders={possibleProviders}
/>
)}
</FilterMenuProvider>
);
}
function ProvidersDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleProviders,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleProviders: string[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ providers: values });
};
const filtered = useMemo(() => {
return possibleProviders.filter((p) => p.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue, possibleProviders]);
return (
<SelectProvider value={values("providers")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by provider..." value={searchValue} />
<SelectList>
{filtered.map((provider) => (
<SelectItem key={provider} value={provider} icon={<ServerIcon className="size-4" />}>
{provider}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No providers found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -156,6 +156,8 @@ export type QueryWidgetProps = {
onDuplicate?: (data: QueryWidgetData) => void;
/** When true, show table column headers even when there are no rows */
showTableHeaderOnEmpty?: boolean;
/** Column names to hide from table display but keep in row data (useful for linking) */
hiddenColumns?: string[];
};
export function QueryWidget({
@@ -406,6 +408,7 @@ type QueryWidgetBodyProps = {
setIsFullscreen: (open: boolean) => void;
isLoading: boolean;
showTableHeaderOnEmpty?: boolean;
hiddenColumns?: string[];
};
function QueryWidgetBody({
@@ -417,6 +420,7 @@ function QueryWidgetBody({
setIsFullscreen,
isLoading,
showTableHeaderOnEmpty,
hiddenColumns,
}: QueryWidgetBodyProps) {
const type = config.type;
@@ -436,6 +440,7 @@ function QueryWidgetBody({
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
hiddenColumns={hiddenColumns}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
@@ -26,6 +26,46 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../pri
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
function useCreateDashboard({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const isFreePlan = plan?.v3Subscription?.isPaying === false;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
return {
isOpen,
setIsOpen,
isAtLimit,
canUpgrade: !!canUpgrade,
isFreePlan,
formAction,
limits,
organization,
};
}
export function CreateDashboardButton({
organization,
project,
@@ -37,29 +77,12 @@ export function CreateDashboardButton({
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
// Close dialog when form submission starts (redirect is happening)
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
const dashboard = useCreateDashboard({ organization, project, environment });
if (isCollapsed) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
@@ -77,15 +100,47 @@ export function CreateDashboardButton({
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAtLimit ? (
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={limits}
canUpgrade={!!canUpgrade}
isFreePlan={plan?.v3Subscription?.isPaying === false}
organization={organization}
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={formAction} limits={limits} />
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
export function CreateDashboardPageButton({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Create custom dashboard
</Button>
</DialogTrigger>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
@@ -105,7 +160,7 @@ function CreateDashboardUpgradeDialog({
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: MatchedOrganization;
organization: { slug: string };
}) {
if (isFreePlan) {
@@ -3,15 +3,17 @@ import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
PuzzlePieceIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-blue-500"
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
@@ -11,6 +11,7 @@ import {
Cog8ToothIcon,
CogIcon,
ExclamationTriangleIcon,
PuzzlePieceIcon,
FolderIcon,
FolderOpenIcon,
GlobeAmericasIcon,
@@ -19,10 +20,13 @@ import {
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
DocumentTextIcon,
ServerStackIcon,
SparklesIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon,
BugAntIcon,
} from "@heroicons/react/20/solid";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
@@ -72,9 +76,12 @@ import {
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3LogsPath,
v3ErrorsPath,
v3PromptsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3ProjectSettingsGeneralPath,
v3ProjectSettingsIntegrationsPath,
v3QueuesPath,
v3RunsPath,
v3SchedulesPath,
@@ -110,6 +117,7 @@ import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { type SideMenuSectionId } from "./sideMenuTypes";
import { IconBugFilled } from "@tabler/icons-react";
/** Get the collapsed state for a specific side menu section from user preferences */
function getSectionCollapsed(
@@ -127,7 +135,7 @@ type SideMenuUser = Pick<
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine"
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
>;
export type SideMenuEnvironment = MatchedEnvironment;
@@ -449,9 +457,39 @@ export function SideMenu({
/>
</div>
<SideMenuSection
title="AI"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"ai"
)}
onCollapseToggle={handleSectionToggle("ai")}
>
<SideMenuItem
name="Prompts"
icon={DocumentTextIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3PromptsPath(organization, project, environment)}
data-action="prompts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="AI Metrics"
icon={SparklesIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={v3BuiltInDashboardPath(organization, project, environment, "llm")}
data-action="ai-metrics"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuSection
title="Insights"
title="Observability"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
@@ -472,6 +510,17 @@ export function SideMenu({
isCollapsed={isCollapsed}
/>
)}
{(user.admin || user.isImpersonating) && (
<SideMenuItem
name="Errors"
icon={IconBugFilled}
activeIconColor="text-amber-500"
inactiveIconColor="text-amber-500"
to={v3ErrorsPath(organization, project, environment)}
data-action="errors"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Query"
icon={TableCellsIcon}
@@ -482,7 +531,7 @@ export function SideMenu({
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Metrics"
name="Dashboards"
icon={ChartBarIcon}
activeIconColor="text-metrics"
inactiveIconColor="text-metrics"
@@ -589,13 +638,34 @@ export function SideMenu({
data-action="limits"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
<SideMenuSection
title="Project settings"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"project-settings"
)}
onCollapseToggle={handleSectionToggle("project-settings")}
>
<SideMenuItem
name="Project settings"
name="General"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
data-action="project-settings-general"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Integrations"
icon={PuzzlePieceIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
data-action="project-settings-integrations"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
@@ -611,6 +681,7 @@ export function SideMenu({
<V3DeprecationPanel
isCollapsed={isCollapsed}
isV3={isV3Project}
projectCreatedAt={project.createdAt}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
@@ -641,15 +712,21 @@ export function SideMenu({
function V3DeprecationPanel({
isCollapsed,
isV3,
projectCreatedAt,
hasIncident,
isManagedCloud,
}: {
isCollapsed: boolean;
isV3: boolean;
projectCreatedAt: Date;
hasIncident: boolean;
isManagedCloud: boolean;
}) {
if (!isManagedCloud || !isV3 || hasIncident) {
// Only show for projects created before v4 was released
const V4_RELEASE_DATE = new Date("2025-09-01");
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
return null;
}
@@ -1,7 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
export const SideMenuSectionIdSchema = z.enum(["ai", "manage", "metrics", "project-settings"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,386 @@
import * as Ariakit from "@ariakit/react";
import {
XMarkIcon,
PlusIcon,
CubeIcon,
MagnifyingGlassIcon,
ChevronDownIcon,
} from "@heroicons/react/20/solid";
import { useCallback, useMemo, useRef, useState } from "react";
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
import { cn } from "~/utils/cn";
import { matchSorter } from "match-sorter";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
const pillColors = [
"bg-green-800/40 border-green-600/50",
"bg-teal-800/40 border-teal-600/50",
"bg-blue-800/40 border-blue-600/50",
"bg-indigo-800/40 border-indigo-600/50",
"bg-violet-800/40 border-violet-600/50",
"bg-purple-800/40 border-purple-600/50",
"bg-fuchsia-800/40 border-fuchsia-600/50",
"bg-pink-800/40 border-pink-600/50",
"bg-rose-800/40 border-rose-600/50",
"bg-orange-800/40 border-orange-600/50",
"bg-amber-800/40 border-amber-600/50",
"bg-yellow-800/40 border-yellow-600/50",
"bg-lime-800/40 border-lime-600/50",
"bg-emerald-800/40 border-emerald-600/50",
"bg-cyan-800/40 border-cyan-600/50",
"bg-sky-800/40 border-sky-600/50",
];
function getPillColor(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return pillColors[Math.abs(hash) % pillColors.length];
}
export const TECHNOLOGY_OPTIONS = [
"Airflow",
"Angular",
"Anthropic",
"Astro",
"Auth0",
"AWS",
"AWS SQS",
"Azure",
"BigQuery",
"BullMQ",
"Bun",
"Cassandra",
"Celery",
"ClickHouse",
"Clerk",
"Cloudflare",
"CockroachDB",
"Cohere",
"Convex",
"Databricks",
"Datadog",
"DeepSeek",
"Deno",
"DigitalOcean",
"Django",
"Docker",
"Drizzle",
"DynamoDB",
"Elasticsearch",
"Electron",
"Elevenlabs",
"Expo",
"Express",
"FastAPI",
"Fastify",
"Firebase",
"Flask",
"Fly.io",
"Gatsby",
"GCP",
"Go",
"Google Cloud Tasks",
"Google Gemini",
"GraphQL",
"Groq",
"Heroku",
"Hono",
"htmx",
"Hugging Face",
"Inngest",
"Kafka",
"Kubernetes",
"LangChain",
"Laravel",
"LlamaIndex",
"MariaDB",
"Midjourney",
"Mistral",
"MongoDB",
"Mongoose",
"MySQL",
"Neo4j",
"Neon",
"Nest.js",
"Netlify",
"Next.js",
"Node.js",
"Nuxt",
"Ollama",
"OpenAI",
"Perplexity",
"PHP",
"Pinecone",
"PlanetScale",
"Python",
"PostHog",
"PostgreSQL",
"Prisma",
"Pulumi",
"RabbitMQ",
"Railway",
"React",
"React Native",
"Redis",
"Redshift",
"Remix",
"Render",
"Replicate",
"Resend",
"Ruby on Rails",
"Rust",
"SendGrid",
"Sentry",
"Sidekiq",
"Snowflake",
"Solid.js",
"Spring Boot",
"SQLite",
"Stability AI",
"Stripe",
"Supabase",
"Svelte",
"SvelteKit",
"Tailwind CSS",
"Temporal",
"Terraform",
"Together AI",
"tRPC",
"Turso",
"Twilio",
"TypeORM",
"Upstash",
"Vercel",
"Vercel AI SDK",
"Vite",
"Vue",
"Weaviate",
] as const;
type TechnologyPickerProps = {
value: string[];
onChange: (value: string[]) => void;
customValues: string[];
onCustomValuesChange: (values: string[]) => void;
};
export function TechnologyPicker({
value,
onChange,
customValues,
onCustomValuesChange,
}: TechnologyPickerProps) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [otherInputValue, setOtherInputValue] = useState("");
const [showOtherInput, setShowOtherInput] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null);
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
const filteredOptions = useMemo(() => {
if (!searchValue) return TECHNOLOGY_OPTIONS;
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
}, [searchValue]);
const toggleOption = useCallback(
(option: string) => {
if (value.includes(option)) {
onChange(value.filter((v) => v !== option));
} else {
onChange([...value, option]);
}
},
[value, onChange]
);
const removeItem = useCallback(
(item: string) => {
if (value.includes(item)) {
onChange(value.filter((v) => v !== item));
} else {
onCustomValuesChange(customValues.filter((v) => v !== item));
}
},
[value, onChange, customValues, onCustomValuesChange]
);
const addCustomValue = useCallback(() => {
const trimmed = otherInputValue.trim();
if (!trimmed) return;
const matchedOption = TECHNOLOGY_OPTIONS.find(
(opt) => opt.toLowerCase() === trimmed.toLowerCase()
);
if (matchedOption) {
if (!value.includes(matchedOption)) {
onChange([...value, matchedOption]);
}
} else if (!customValues.includes(trimmed) && !value.includes(trimmed)) {
onCustomValuesChange([...customValues, trimmed]);
}
setOtherInputValue("");
}, [otherInputValue, customValues, onCustomValuesChange, value, onChange]);
const handleOtherKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addCustomValue();
}
},
[addCustomValue]
);
return (
<div className="flex flex-col gap-2">
{allSelected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allSelected.map((item) => (
<span
key={item}
className={cn(
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
getPillColor(item)
)}
>
{item}
<button
type="button"
onClick={() => removeItem(item)}
aria-label={`Remove ${item}`}
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
>
<XMarkIcon className="size-3.5" />
</button>
</span>
))}
</div>
)}
<Ariakit.ComboboxProvider
resetValueOnHide
setValue={(val) => {
setSearchValue(val);
}}
>
<Ariakit.SelectProvider
open={open}
setOpen={setOpen}
value={value}
setValue={(v) => {
if (Array.isArray(v)) {
onChange(v);
}
}}
virtualFocus
>
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
<div className="flex grow items-center">
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
<span>Select your technologies</span>
</div>
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
</Ariakit.Select>
<Ariakit.SelectPopover
gutter={5}
unmountOnHide
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,var(--popover-anchor-width))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(400px,var(--popover-available-height))]"
)}
>
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
<Ariakit.Combobox
autoSelect
placeholder="Search technologies…"
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
/>
</div>
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
{filteredOptions.map((option) => (
<Ariakit.ComboboxItem
key={option}
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
onClick={(e) => {
e.preventDefault();
toggleOption(option);
}}
>
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
<CheckboxIndicator checked={value.includes(option)} />
<span className="grow truncate text-text-bright">{option}</span>
</div>
</Ariakit.ComboboxItem>
))}
{filteredOptions.length === 0 && searchValue && (
<div className="px-3 py-2 text-xs text-text-dimmed">
No matches for &ldquo;{searchValue}&rdquo;
</div>
)}
</Ariakit.ComboboxList>
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
{showOtherInput ? (
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
<input
ref={otherInputRef}
type="text"
value={otherInputValue}
onChange={(e) => setOtherInputValue(e.target.value)}
onKeyDown={handleOtherKeyDown}
placeholder="Type and press Enter to add"
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
autoFocus
/>
<ShortcutKey
shortcut={{ key: "Enter" }}
variant="small"
className={cn(
"mr-1.5 transition-opacity duration-150",
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
)}
/>
<button
type="button"
onClick={() => {
setOtherInputValue("");
setShowOtherInput(false);
}}
className="flex items-center text-text-dimmed hover:text-text-bright"
>
<XMarkIcon className="size-4" />
</button>
</div>
) : (
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
onClick={() => {
setShowOtherInput(true);
setTimeout(() => otherInputRef.current?.focus(), 0);
}}
>
<PlusIcon className="size-4 flex-none" />
<span>Other (not listed)</span>
</button>
)}
</div>
</Ariakit.SelectPopover>
</Ariakit.SelectProvider>
</Ariakit.ComboboxProvider>
</div>
);
}
@@ -1,8 +1,10 @@
import {
BoltIcon,
BuildingOffice2Icon,
CodeBracketSquareIcon,
FaceSmileIcon,
FireIcon,
GlobeAltIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
@@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string().url(),
url: z.string(),
lastIconHex: z.string().optional(),
}),
]);
@@ -85,6 +88,7 @@ export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGS
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
"hero:bolt": BoltIcon,
};
export const defaultAvatarColors = [
@@ -179,9 +183,21 @@ function AvatarIcon({
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid place-items-center" style={styleFromSize(size)}>
<img src={avatar.url} alt="Organization avatar" className="size-6" />
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<img
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
/>
</span>
);
}
@@ -1,10 +1,18 @@
import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react";
import React, { forwardRef, type ReactNode, useImperativeHandle, useRef } from "react";
import React, {
forwardRef,
type ReactNode,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
import { Icon, type RenderIcon } from "./Icon";
import { Spinner } from "./Spinner";
const sizes = {
small: {
@@ -180,6 +188,7 @@ export type ButtonContentPropsType = {
tooltip?: ReactNode;
iconSpacing?: string;
hideShortcutKey?: boolean;
isLoading?: boolean;
};
export function ButtonContent(props: ButtonContentPropsType) {
@@ -196,7 +205,19 @@ export function ButtonContent(props: ButtonContentPropsType) {
tooltip,
iconSpacing,
hideShortcutKey,
isLoading,
} = props;
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
if (!isLoading) {
setShowSpinner(false);
return;
}
const timer = setTimeout(() => setShowSpinner(true), 200);
return () => clearTimeout(timer);
}, [isLoading]);
const variation = allVariants.variant[props.variant];
const btnClassName = cn(allVariants.$all, variation.button);
@@ -217,56 +238,64 @@ export function ButtonContent(props: ButtonContentPropsType) {
const buttonContent = (
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName,
iconSpacing
<div className={cn("relative", "flex w-full items-center")}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName,
iconSpacing,
showSpinner && "invisible"
)}
>
{LeadingIcon && (
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
/>
)}
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon && (
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
{showSpinner && (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-3.5" color="white" />
</span>
)}
>
{LeadingIcon && (
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
/>
)}
{text &&
(typeof text === "string" ? (
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
{text}
</span>
) : (
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon && (
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
</div>
);
@@ -298,6 +327,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
const isDisabled = disabled || props.isLoading;
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
@@ -307,14 +338,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
e.stopPropagation();
}
},
disabled: disabled || !props.shortcut,
disabled: isDisabled || !props.shortcut,
});
return (
<button
className={cn("group/button outline-none focus-custom", props.fullWidth ? "w-full" : "")}
type={type}
disabled={disabled}
disabled={isDisabled}
onClick={onClick}
name={props.name}
value={props.value}
@@ -0,0 +1,24 @@
import { cn } from "~/utils/cn";
export function CheckboxIndicator({ checked }: { checked: boolean }) {
return (
<div
className={cn(
"flex size-4 flex-none items-center justify-center rounded border",
checked ? "border-indigo-500 bg-indigo-600" : "border-charcoal-600 bg-charcoal-700"
)}
>
{checked && (
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 6L5 8.5L9.5 3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
}
@@ -1,7 +1,8 @@
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { useRouteLoaderData } from "@remix-run/react";
import { formatDistanceToNow } from "date-fns";
import { Laptop } from "lucide-react";
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
import { memo, type ReactNode, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { CopyButton } from "./CopyButton";
import { useLocales } from "./LocaleProvider";
import { Paragraph } from "./Paragraph";
@@ -357,6 +358,54 @@ function formatDateTimeAccurate(
return `${datePart} ${timePart}`;
}
type RelativeDateTimeProps = {
date: Date | string;
timeZone?: string;
};
function getRelativeText(date: Date): string {
const text = formatDistanceToNow(date, { addSuffix: true });
return text.charAt(0).toUpperCase() + text.slice(1);
}
export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate));
// Every 60s refresh
useEffect(() => {
const interval = setInterval(() => {
setRelativeText(getRelativeText(realDate));
}, 60_000);
return () => clearInterval(interval);
}, [realDate]);
// On first render
useEffect(() => {
setRelativeText(getRelativeText(realDate));
}, [realDate]);
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{relativeText}</span>}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
side="right"
asChild={true}
/>
);
};
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
@@ -4,16 +4,19 @@ import React from "react";
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
import { cn } from "~/utils/cn";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
autosaveStrategy={props.autosaveId ? "cookie" : undefined}
{...props}
/>
);
const ResizablePanelGroup = ({ className, snapshot, ...props }: React.ComponentProps<typeof PanelGroup>) => {
return (
<PanelGroup
className={cn(
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
className
)}
autosaveStrategy={props.autosaveId ? "cookie" : undefined}
snapshot={snapshot}
{...props}
/>
);
};
const ResizablePanel = Panel;
@@ -3,23 +3,28 @@ 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";
import { useSearchParams } from "~/hooks/useSearchParam";
import { cn } from "~/utils/cn";
export function LogsSearchInput() {
const location = useOptimisticLocation();
export type SearchInputProps = {
placeholder?: string;
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
resetParams?: string[];
};
export function SearchInput({
placeholder = "Search logs…",
resetParams = ["cursor", "direction"],
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
// Get initial search value from URL
const initialSearch = value("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 urlSearch = value("search") ?? "";
if (urlSearch !== text && !isFocused) {
@@ -28,21 +33,22 @@ export function LogsSearchInput() {
}, [value, text, isFocused]);
const handleSubmit = useCallback(() => {
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
if (text.trim()) {
replace({ search: text.trim() });
replace({ search: text.trim(), ...resetValues });
} else {
del("search");
del(["search", ...resetParams]);
}
}, [text, replace, del]);
}, [text, replace, del, resetParams]);
const handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", "cursor", "direction"]);
del(["search", ...resetParams]);
},
[del]
[del, resetParams]
);
return (
@@ -61,7 +67,7 @@ export function LogsSearchInput() {
type="text"
ref={inputRef}
variant="secondary-small"
placeholder="Search logs…"
placeholder={placeholder}
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
@@ -80,12 +86,12 @@ export function LogsSearchInput() {
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1.5">
<ShortcutKey shortcut={{ key: "enter" }} variant="medium" className="border-none" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
@@ -338,9 +338,9 @@ export function SelectTrigger({
/>
}
>
<div className="flex grow items-center gap-0.5">
{icon && <div className="-ml-1 flex-none">{icon}</div>}
<div className="truncate">{content}</div>
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
{icon && <div className="flex-none">{icon}</div>}
<div className="min-w-0 truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
@@ -443,21 +443,33 @@ export function SelectList(props: SelectListProps) {
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
checkPosition?: "left" | "right";
shortcut?: ShortcutDefinition;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
import { CheckboxIndicator } from "./CheckboxIndicator";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
checkPosition = "right",
shortcut,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
const isChecked = React.useMemo(() => {
if (!props.value || selectValue == null) return false;
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
return selectValue === props.value;
}, [selectValue, props.value]);
useShortcutKeys({
shortcut: shortcut,
@@ -484,10 +496,16 @@ export function SelectItem({
)}
ref={ref}
>
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
<div
className={cn(
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
@@ -176,10 +176,22 @@ type TableCellBasicProps = {
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
disableTooltipHoverableContent?: boolean;
};
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
hiddenLabel = false,
tooltip,
disableTooltipHoverableContent = false,
},
ref
) => {
const { variant } = useContext(TableContext);
let alignmentClassName = "text-left";
switch (alignment) {
@@ -222,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
disableHoverableContent={disableTooltipHoverableContent}
/>
</div>
) : (
@@ -69,21 +69,26 @@ export function ToastUI({
width: toastWidth,
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
<div
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
>
{variant === "success" ? (
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
) : (
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
)}
<div className="flex flex-col">
{title && <Header2 className="pt-0">{title}</Header2>}
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
<Paragraph
variant={title ? "small/dimmed" : "small/bright"}
className={title ? "pb-1 pt-0.5" : ""}
>
{message}
</Paragraph>
<Action action={action} toastId={t} className="my-2" />
</div>
<button
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
className={cn("-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright", title && "-mt-1")}
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="size-4" />
@@ -1,10 +1,11 @@
import type { OutputColumnMetadata } from "@internal/tsql";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
import { Hash } from "lucide-react";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { createValueFormatter } from "~/utils/columnFormat";
import { AnimatedNumber } from "../AnimatedNumber";
import { ChartBlankState } from "./ChartBlankState";
import { Spinner } from "../Spinner";
@@ -130,6 +131,15 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);
// Look up column format for format-aware display
const columnValueFormatter = useMemo(() => {
const columnMeta = columns.find((c) => c.name === column);
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
| ColumnFormatType
| undefined;
return createValueFormatter(formatType);
}, [columns, column]);
if (isLoading) {
return (
<div className="grid h-full place-items-center [container-type:size]">
@@ -142,6 +152,21 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
return <ChartBlankState icon={Hash} message="No data to display" />;
}
// Use format-aware formatter when available
if (columnValueFormatter) {
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<span>{columnValueFormatter(result)}</span>
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
</div>
</div>
</div>
);
}
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
@@ -149,7 +174,7 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap font-normal tabular-nums leading-none text-text-bright text-[clamp(24px,12cqw,96px)]">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
{(unitSuffix || suffix) && (
@@ -104,6 +104,8 @@ const ChartTooltipContent = React.forwardRef<
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
}
>(
(
@@ -121,6 +123,7 @@ const ChartTooltipContent = React.forwardRef<
color,
nameKey,
labelKey,
valueFormatter,
},
ref
) => {
@@ -221,9 +224,11 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
{valueFormatter && typeof item.value === "number"
? valueFormatter(item.value)
: item.value.toLocaleString()}
</span>
)}
</div>
@@ -38,6 +38,8 @@ export type ChartBarRendererProps = {
referenceLine?: ReferenceLineProps;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
@@ -62,6 +64,7 @@ export function ChartBarRenderer({
yAxisProps: yAxisPropsProp,
referenceLine,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartBarRendererProps) {
@@ -159,7 +162,7 @@ export function ChartBarRenderer({
showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent />
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
@@ -26,6 +26,8 @@ export type ChartLegendCompoundProps = {
totalLabel?: string;
/** Aggregation method controls the header label and how totals are computed */
aggregation?: AggregationType;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
@@ -50,6 +52,7 @@ export function ChartLegendCompound({
className,
totalLabel,
aggregation,
valueFormatter,
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
@@ -74,12 +77,12 @@ export function ChartLegendCompound({
const currentTotal = useMemo((): number | null => {
if (!activePayload?.length) return grandTotal;
// Collect all series values from the hovered data point, preserving nulls
const rawValues = activePayload
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
.map((item) => item.value);
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
const dataRow = activePayload[0]?.payload;
if (!dataRow) return grandTotal;
const rawValues = dataKeys.map((key) => dataRow[key]);
// Filter to non-null values only
const values = rawValues
.filter((v): v is number => v != null)
.map((v) => Number(v) || 0);
@@ -88,7 +91,6 @@ export function ChartLegendCompound({
if (values.length === 0) return null;
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
@@ -113,24 +115,24 @@ export function ChartLegendCompound({
const currentData = useMemo((): Record<string, number | null> => {
if (!activePayload?.length) return totals;
// If we have activePayload data from hovering over a bar/line
const hoverData = activePayload.reduce(
(acc, item) => {
if (item.dataKey && item.value !== undefined) {
// Preserve null for gap-filled points instead of coercing to 0
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
}
return acc;
},
{} as Record<string, number | null>
);
// Use the full data row so ALL dataKeys are resolved from the hovered point,
// not just the visibleSeries present in activePayload.
const dataRow = activePayload[0]?.payload;
if (!dataRow) return totals;
const hoverData: Record<string, number | null> = {};
for (const key of dataKeys) {
const value = dataRow[key];
if (value !== undefined) {
hoverData[key] = value != null ? Number(value) || 0 : null;
}
}
// Return a merged object - totals for keys not in the hover data
return {
...totals,
...hoverData,
};
}, [activePayload, totals]);
}, [activePayload, totals, dataKeys]);
// Prepare legend items with capped display
const legendItems = useMemo(() => {
@@ -180,7 +182,11 @@ export function ChartLegendCompound({
<span className="font-medium">{currentTotalLabel}</span>
<span className="font-medium tabular-nums">
{currentTotal != null ? (
<AnimatedNumber value={currentTotal} duration={0.25} />
valueFormatter ? (
valueFormatter(currentTotal)
) : (
<AnimatedNumber value={currentTotal} duration={0.25} />
)
) : (
"\u2013"
)}
@@ -252,7 +258,11 @@ export function ChartLegendCompound({
)}
>
{total != null ? (
<AnimatedNumber value={total} duration={0.25} />
valueFormatter ? (
valueFormatter(total)
) : (
<AnimatedNumber value={total} duration={0.25} />
)
) : (
"\u2013"
)}
@@ -269,6 +279,7 @@ export function ChartLegendCompound({
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
remainingCount={legendItems.remaining - 1}
valueFormatter={valueFormatter}
/>
) : (
<ViewAllDataRow
@@ -315,9 +326,10 @@ type HoveredHiddenItemRowProps = {
item: { dataKey: string; color?: string; label: React.ReactNode };
value: number | null;
remainingCount: number;
valueFormatter?: (value: number) => string;
};
function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItemRowProps) {
function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: HoveredHiddenItemRowProps) {
return (
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
{/* Active highlight background */}
@@ -339,7 +351,15 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="tabular-nums text-text-bright">
{value != null ? <AnimatedNumber value={value} duration={0.25} /> : "\u2013"}
{value != null ? (
valueFormatter ? (
valueFormatter(value)
) : (
<AnimatedNumber value={value} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
@@ -51,6 +51,8 @@ export type ChartLineRendererProps = {
stacked?: boolean;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
@@ -75,6 +77,7 @@ export function ChartLineRenderer({
yAxisProps: yAxisPropsProp,
stacked = false,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartLineRendererProps) {
@@ -157,7 +160,13 @@ export function ChartLineRenderer({
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={showLegend ? () => null : <ChartTooltipContent indicator="line" />}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent indicator="line" valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
@@ -205,7 +214,13 @@ export function ChartLineRenderer({
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={showLegend ? () => null : <ChartTooltipContent />}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
@@ -34,6 +34,8 @@ export type ChartRootProps = {
legendTotalLabel?: string;
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
legendAggregation?: AggregationType;
/** Optional formatter for numeric legend values (e.g. bytes, duration) */
legendValueFormatter?: (value: number) => string;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
@@ -82,6 +84,7 @@ export function ChartRoot({
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
@@ -108,6 +111,7 @@ export function ChartRoot({
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
legendAggregation={legendAggregation}
legendValueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
fillContainer={fillContainer}
@@ -126,6 +130,7 @@ type ChartRootInnerProps = {
maxLegendItems?: number;
legendTotalLabel?: string;
legendAggregation?: AggregationType;
legendValueFormatter?: (value: number) => string;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
fillContainer?: boolean;
@@ -140,6 +145,7 @@ function ChartRootInner({
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
@@ -184,6 +190,7 @@ function ChartRootInner({
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
aggregation={legendAggregation}
valueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
/>
@@ -945,11 +945,9 @@ export function QueryEditor({
<ResizableHandle id="query-handle" />
<ResizablePanel
id="query-help"
min="200px"
collapsible
collapsedSize="20px"
min="380px"
default="400px"
max="500px"
max="800px"
className="w-full"
>
<QueryHelpSidebar
@@ -1175,9 +1173,9 @@ function QueryResultsCallouts({
<div className="flex flex-col gap-2 px-2 pt-2">
{hiddenColumns && hiddenColumns.length > 0 && (
<Callout variant="warning" className="shrink-0 text-sm">
<code>SELECT *</code> doesn't return all columns because it's slow. The following columns
are not shown: <span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>.
Specify them explicitly to include them.
<code>SELECT *</code> returns core columns only. To include{" "}
<span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>, add them to your
SELECT explicitly.
</Callout>
)}
{periodClipped && (
@@ -0,0 +1,148 @@
import { lazy, Suspense, useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Header3 } from "~/components/primitives/Headers";
import { TextLink } from "~/components/primitives/TextLink";
import { tryPrettyJson } from "./ai/aiHelpers";
import { SpanMetricRow as MetricRow } from "./ai/SpanMetricRow";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { v3PromptPath } from "~/utils/pathBuilder";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children }: { children: string }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
type PromptTab = "overview" | "input" | "template";
export function PromptSpanDetails({ promptData }: { promptData: PromptSpanData }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptPath =
organization && project && environment
? v3PromptPath(organization, project, environment, promptData.slug, promptData.version)
: undefined;
const hasInput = !!promptData.input;
const hasTemplate = !!promptData.template;
const availableTabs: PromptTab[] = [
"overview",
...(hasInput ? (["input"] as const) : []),
...(hasTemplate ? (["template"] as const) : []),
];
const [tab, setTab] = useState<PromptTab>("overview");
const labels = promptData.labels
? promptData.labels
.split(",")
.map((l) => l.trim())
.filter(Boolean)
: [];
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<TabContainer>
{availableTabs.map((t) => (
<TabButton
key={t}
isActive={tab === t}
layoutId="prompt-span"
onClick={() => setTab(t)}
shortcut={
t === "overview" ? { key: "o" } : t === "input" ? { key: "i" } : { key: "t" }
}
>
{t === "overview" ? "Overview" : t === "input" ? "Input" : "Template"}
</TabButton>
))}
</TabContainer>
</div>
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{tab === "overview" && (
<div className="flex flex-col px-3">
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow
label="Prompt"
value={
promptPath ? (
<TextLink to={promptPath}>{promptData.slug}</TextLink>
) : (
promptData.slug
)
}
/>
<MetricRow label="Version" value={`v${promptData.version}`} />
{labels.length > 0 && <MetricRow label="Labels" value={labels.join(", ")} />}
{promptData.model && <MetricRow label="Model" value={promptData.model} />}
</div>
</div>
{promptData.text && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Resolved content</Header3>
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={
<span className="whitespace-pre-wrap">
{promptData.text.length > 300
? promptData.text.slice(0, 300) + "..."
: promptData.text}
</span>
}
>
<StreamdownRenderer>{promptData.text}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
)}
{tab === "input" && hasInput && (
<div className="px-3 py-2.5">
<CodeBlock
code={tryPrettyJson(promptData.input!)}
maxLines={30}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{tab === "template" && hasTemplate && (
<div className="px-3 py-2.5">
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={
<span className="whitespace-pre-wrap">{promptData.template!}</span>
}
>
<StreamdownRenderer>{promptData.template!}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -9,7 +9,7 @@ import {
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import { IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
import { IconBugFilled, IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
import { MachinePresetName } from "@trigger.dev/core/v3";
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
@@ -181,6 +181,7 @@ export const TaskRunListSearchFilters = z.object({
machines: MachinePresetOrMachinePresetArray.describe(
`Machine presets to filter by (${machines.join(", ")})`
),
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -220,6 +221,8 @@ export function filterTitle(filterKey: string) {
return "Machine";
case "versions":
return "Version";
case "errorId":
return "Error ID";
default:
return filterKey;
}
@@ -258,6 +261,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
return <MachineDefaultIcon className="size-4" />;
case "versions":
return <IconRotateClockwise2 className="size-4" />;
case "errorId":
return <IconBugFilled className="size-4" />;
default:
return undefined;
}
@@ -304,6 +309,7 @@ export function getRunFiltersFromSearchParams(
searchParams.getAll("versions").filter((v) => v.length > 0).length > 0
? searchParams.getAll("versions")
: undefined,
errorId: searchParams.get("errorId") ?? undefined,
};
const parsed = TaskRunListSearchFilters.safeParse(params);
@@ -344,7 +350,8 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("scheduleId") ||
searchParams.has("queues") ||
searchParams.has("machines") ||
searchParams.has("versions");
searchParams.has("versions") ||
searchParams.has("errorId");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -380,6 +387,7 @@ const filterTypes = [
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -434,6 +442,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
<AppliedBatchIdFilter />
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
<AppliedErrorIdFilter />
</>
);
}
@@ -470,6 +479,8 @@ function Menu(props: MenuProps) {
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "versions":
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "error":
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
@@ -655,7 +666,7 @@ function TasksDropdown({
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
<MiddleTruncate text={item.slug}/>
<MiddleTruncate text={item.slug} />
</SelectItem>
))}
</SelectList>
@@ -1740,3 +1751,121 @@ function AppliedScheduleIdFilter() {
</FilterMenuProvider>
);
}
function ErrorIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const errorIdValue = value("errorId");
const [errorId, setErrorId] = useState(errorIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
errorId: errorId === "" ? undefined : errorId?.toString(),
});
setOpen(false);
}, [errorId, replace]);
let error: string | undefined = undefined;
if (errorId) {
if (!errorId.startsWith("error_")) {
error = "Error IDs start with 'error_'";
}
}
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>Error ID</Label>
<Input
placeholder="error_"
value={errorId ?? ""}
onChange={(e) => setErrorId(e.target.value)}
variant="small"
className="w-[29ch] 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 || !errorId}
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedErrorIdFilter() {
const { value, del } = useSearchParams();
if (value("errorId") === undefined) {
return null;
}
const errorId = value("errorId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ErrorIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Error ID"
icon={filterIcon("errorId")}
value={errorId}
onRemove={() => del(["errorId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -3,10 +3,25 @@ import {
HandRaisedIcon,
InformationCircleIcon,
RectangleStackIcon,
SparklesIcon,
Squares2X2Icon,
TableCellsIcon,
TagIcon,
WrenchIcon,
} from "@heroicons/react/20/solid";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
import {
AnthropicIcon,
AzureIcon,
CerebrasIcon,
DeepseekIcon,
GeminiIcon,
LlamaIcon,
MistralIcon,
OpenAIIcon,
PerplexityIcon,
XAIIcon,
} from "~/assets/icons/AiProviderIcons";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
@@ -112,6 +127,31 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
case "hero-sparkles":
return <SparklesIcon className={cn(className, "text-text-dimmed")} />;
case "hero-wrench":
return <WrenchIcon className={cn(className, "text-text-dimmed")} />;
case "tabler-brand-anthropic":
case "ai-provider-anthropic":
return <AnthropicIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-openai":
return <OpenAIIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-gemini":
return <GeminiIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-llama":
return <LlamaIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-deepseek":
return <DeepseekIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-xai":
return <XAIIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-perplexity":
return <PerplexityIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-cerebras":
return <CerebrasIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-mistral":
return <MistralIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-azure":
return <AzureIcon className={cn(className, "text-text-dimmed")} />;
}
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
@@ -3,6 +3,8 @@ import { TaskEventStyle } from "@trigger.dev/core/v3";
import type { TaskEventLevel } from "@trigger.dev/database";
import { Fragment } from "react";
import { cn } from "~/utils/cn";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
type SpanTitleProps = {
message: string;
@@ -45,20 +47,44 @@ function SpanAccessory({
/>
);
}
case "pills": {
return (
<span className="flex items-center gap-1">
{accessory.items.map((item, index) => (
<SpanPill key={index} text={item.text} icon={item.icon} />
))}
</span>
);
}
default: {
return (
<div className={cn("flex gap-1")}>
<span className={cn("flex gap-1")}>
{accessory.items.map((item, index) => (
<span key={index} className={cn("inline-flex items-center gap-1")}>
{item.text}
</span>
))}
</div>
</span>
);
}
}
}
function SpanPill({ text, icon }: { text: string; icon?: string }) {
const hasIcon = icon && tablerIcons.has(icon);
return (
<span className="inline-flex items-center gap-0.5 rounded-full border border-charcoal-700 bg-charcoal-850 px-1.5 py-px text-xxs text-text-dimmed">
{hasIcon && (
<svg className="size-3 stroke-[1.5] text-text-dimmed/70">
<use xlinkHref={`${tablerSpritePath}#${icon}`} />
</svg>
)}
<span className="truncate">{text}</span>
</span>
);
}
export function SpanCodePathAccessory({
accessory,
className,
@@ -69,6 +69,7 @@ type RunsTableProps = {
allowSelection?: boolean;
variant?: TableVariant;
disableAdjacentRows?: boolean;
additionalTableState?: Record<string, string>;
};
export function TaskRunsTable({
@@ -81,6 +82,7 @@ export function TaskRunsTable({
isLoading = false,
allowSelection = false,
variant = "dimmed",
additionalTableState,
}: RunsTableProps) {
const organization = useOrganization();
const project = useProject();
@@ -89,8 +91,16 @@ export function TaskRunsTable({
const { isManagedCloud } = useFeatures();
const { value } = useSearchParams();
const location = useOptimisticLocation();
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
const params = new URLSearchParams(location.search || "");
if (!value("rootOnly")) {
params.set("rootOnly", String(rootOnlyDefault));
}
if (additionalTableState) {
for (const [key, val] of Object.entries(additionalTableState)) {
params.set(key, val);
}
}
const search = params.toString();
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
@@ -0,0 +1,353 @@
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
ClipboardDocumentIcon,
CodeBracketSquareIcon,
} from "@heroicons/react/20/solid";
import { lazy, Suspense, useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import type { DisplayItem, ToolUse } from "./types";
// Lazy load streamdown to avoid SSR issues
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children }: { children: string }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
export type PromptLink = {
slug: string;
version?: string;
path: string;
};
export function AIChatMessages({
items,
promptLink,
}: {
items: DisplayItem[];
promptLink?: PromptLink;
}) {
return (
<div className="flex flex-col gap-1">
{items.map((item, i) => {
switch (item.type) {
case "system":
return <SystemSection key={i} text={item.text} promptLink={promptLink} />;
case "user":
return <UserSection key={i} text={item.text} />;
case "tool-use":
return <ToolUseSection key={i} tools={item.tools} />;
case "assistant":
return <AssistantResponse key={i} text={item.text} />;
}
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Section header (shared across all sections)
// ---------------------------------------------------------------------------
function SectionHeader({ label, right }: { label: string; right?: React.ReactNode }) {
return (
<div className="flex items-center justify-between">
<Header3>{label}</Header3>
{right && <div className="flex items-center gap-2">{right}</div>}
</div>
);
}
export function ChatBubble({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
function SystemSection({
text,
promptLink,
}: {
text: string;
promptLink?: PromptLink;
}) {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > 150;
const preview = isLong ? text.slice(0, 150) + "..." : text;
const displayText = expanded || !isLong ? text : preview;
return (
<div className="flex flex-col gap-1.5 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Header3>System</Header3>
{promptLink && (
<LinkButton to={promptLink.path} variant="minimal/small">
<span className="flex items-center gap-1">
<svg className="size-3.5 shrink-0 text-text-dimmed">
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
</svg>
{promptLink.slug}
{promptLink.version ? ` v${promptLink.version}` : ""}
</span>
</LinkButton>
)}
</div>
{isLong && (
<Button
variant="minimal/small"
onClick={() => setExpanded(!expanded)}
LeadingIcon={expanded ? ChevronUpIcon : ChevronDownIcon}
aria-label={expanded ? "Collapse" : "Expand"}
aria-expanded={expanded}
/>
)}
</div>
<ChatBubble>
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense fallback={<span className="whitespace-pre-wrap">{displayText}</span>}>
<StreamdownRenderer>{displayText}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
</div>
);
}
// ---------------------------------------------------------------------------
// User
// ---------------------------------------------------------------------------
function UserSection({ text }: { text: string }) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label="User" />
<ChatBubble>
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
<StreamdownRenderer>{text}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
</div>
);
}
// ---------------------------------------------------------------------------
// Assistant response (with markdown/raw toggle)
// ---------------------------------------------------------------------------
function isJsonString(value: string): boolean {
const trimmed = value.trimStart();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
export function AssistantResponse({
text,
headerLabel = "Assistant",
}: {
text: string;
headerLabel?: string;
}) {
const isJson = isJsonString(text);
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
const [copied, setCopied] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (isJson) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label={headerLabel} />
<CodeBlock
code={text}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
);
}
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader
label={headerLabel}
right={
<div className="flex items-center">
<Button
variant="minimal/small"
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
LeadingIcon={CodeBracketSquareIcon}
>
{mode === "rendered" ? "Raw" : "Rendered"}
</Button>
<Button
variant="minimal/small"
onClick={handleCopy}
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
leadingIconClassName={copied ? "text-green-500" : undefined}
>
Copy
</Button>
</div>
}
/>
{mode === "rendered" ? (
<ChatBubble>
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
<StreamdownRenderer>{text}</StreamdownRenderer>
</Suspense>
</div>
</ChatBubble>
) : (
<CodeBlock
code={text}
maxLines={20}
showLineNumbers={false}
showCopyButton={false}
className="pl-2"
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Tool use (merged calls + results)
// ---------------------------------------------------------------------------
function ToolUseSection({ tools }: { tools: ToolUse[] }) {
return (
<div className="flex flex-col gap-1.5 py-2.5">
<SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} />
<div className="flex flex-col gap-2">
{tools.map((tool) => (
<ToolUseRow key={tool.toolCallId} tool={tool} />
))}
</div>
</div>
);
}
type ToolTab = "input" | "output" | "details";
function ToolUseRow({ tool }: { tool: ToolUse }) {
const hasInput = tool.inputJson !== "{}";
const hasResult = !!tool.resultOutput;
const hasDetails = !!tool.description || !!tool.parametersJson;
const availableTabs: ToolTab[] = [
...(hasInput ? (["input"] as const) : []),
...(hasResult ? (["output"] as const) : []),
...(hasDetails ? (["details"] as const) : []),
];
const defaultTab: ToolTab | null = hasInput ? "input" : null;
const [activeTab, setActiveTab] = useState<ToolTab | null>(defaultTab);
function handleTabClick(tab: ToolTab) {
setActiveTab(activeTab === tab ? null : tab);
}
return (
<div className="rounded-sm border border-grid-bright bg-charcoal-800/40">
<div className="flex items-center gap-2 px-2.5 py-1.5">
<code className="font-mono text-xs text-text-bright">{tool.toolName}</code>
{tool.resultSummary && (
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
)}
</div>
{availableTabs.length > 0 && (
<>
<div className="flex gap-0 border-t border-grid-bright">
{availableTabs.map((tab) => (
<button
key={tab}
onClick={() => handleTabClick(tab)}
className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
activeTab === tab
? "bg-charcoal-750 text-text-bright"
: "text-text-dimmed hover:text-text-bright"
}`}
>
{tab}
</button>
))}
</div>
{activeTab === "input" && hasInput && (
<div className="border-t border-grid-dimmed">
<CodeBlock
code={tool.inputJson}
maxLines={12}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
{activeTab === "output" && hasResult && (
<div className="border-t border-grid-dimmed">
<CodeBlock
code={tool.resultOutput!}
maxLines={16}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
{activeTab === "details" && hasDetails && (
<div className="flex flex-col gap-2 border-t border-grid-dimmed px-2.5 py-2">
{tool.description && (
<p className="text-xs leading-relaxed text-text-dimmed">{tool.description}</p>
)}
{tool.parametersJson && (
<div>
<span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed">
Parameters schema
</span>
<CodeBlock
code={tool.parametersJson}
maxLines={16}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { formatDuration } from "./aiHelpers";
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
export type AIEmbedData = {
model: string;
provider: string;
value?: string;
durationMs: number;
};
export function extractAIEmbedData(
properties: Record<string, unknown>,
durationMs: number
): AIEmbedData | undefined {
const ai = properties.ai;
if (!ai || typeof ai !== "object") return undefined;
const a = ai as Record<string, unknown>;
if (a.operationId !== "ai.embed") return undefined;
const aiModel = a.model;
if (!aiModel || typeof aiModel !== "object") return undefined;
const m = aiModel as Record<string, unknown>;
const model = typeof m.id === "string" ? m.id : undefined;
if (!model) return undefined;
return {
model,
provider: typeof m.provider === "string" ? m.provider : "unknown",
value: typeof a.value === "string" ? a.value : undefined,
durationMs,
};
}
export function AIEmbedSpanDetails({ data }: { data: AIEmbedData }) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col px-3">
{/* Model info */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow label="Model" value={data.model} />
<MetricRow label="Provider" value={data.provider} />
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
</div>
</div>
{/* Input value */}
{data.value && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
<Paragraph variant="small/dimmed">{data.value}</Paragraph>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import type { ReactNode } from "react";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { Header3 } from "~/components/primitives/Headers";
import { TextLink } from "~/components/primitives/TextLink";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { v3PromptPath } from "~/utils/pathBuilder";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import type { AISpanData } from "./types";
export function AITagsRow({ aiData }: { aiData: AISpanData }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptLink =
aiData.promptSlug && organization && project && environment
? v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion)
: undefined;
return (
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
{aiData.responseId && (
<MetricRow label="Response ID" value={<TruncatedCopyableValue value={aiData.responseId} />} />
)}
<MetricRow label="Model" value={aiData.model} />
{aiData.provider !== "unknown" && <MetricRow label="Provider" value={aiData.provider} />}
{aiData.resolvedProvider && (
<MetricRow label="Resolved provider" value={aiData.resolvedProvider} />
)}
{aiData.promptSlug && (
<MetricRow
label="Prompt"
value={
promptLink ? (
<TextLink to={promptLink}>
{aiData.promptSlug}
{aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}
</TextLink>
) : (
`${aiData.promptSlug}${aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}`
)
}
/>
)}
{aiData.finishReason && <MetricRow label="Finish reason" value={aiData.finishReason} />}
{aiData.serviceTier && <MetricRow label="Service tier" value={aiData.serviceTier} />}
{aiData.toolChoice && <MetricRow label="Tool choice" value={aiData.toolChoice} />}
{aiData.toolCount != null && aiData.toolCount > 0 && (
<MetricRow
label="Tools provided"
value={`${aiData.toolCount} ${aiData.toolCount === 1 ? "tool" : "tools"}`}
/>
)}
{aiData.messageCount != null && (
<MetricRow
label="Messages"
value={`${aiData.messageCount} ${aiData.messageCount === 1 ? "message" : "messages"}`}
/>
)}
{aiData.telemetryMetadata &&
Object.entries(aiData.telemetryMetadata)
.filter(([key]) => key !== "prompt")
.map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
</div>
</div>
);
}
export function AIStatsSummary({ aiData }: { aiData: AISpanData }) {
return (
<div className="flex flex-col gap-1 py-2.5">
<Header3>Stats</Header3>
<div className="flex flex-col text-xs @container">
<MetricRow label="Input" value={aiData.inputTokens.toLocaleString()} unit="tokens" />
<MetricRow label="Output" value={aiData.outputTokens.toLocaleString()} unit="tokens" />
{aiData.cachedTokens != null && aiData.cachedTokens > 0 && (
<MetricRow
label="Cache read"
value={aiData.cachedTokens.toLocaleString()}
unit="tokens"
/>
)}
{aiData.cacheCreationTokens != null && aiData.cacheCreationTokens > 0 && (
<MetricRow
label="Cache write"
value={aiData.cacheCreationTokens.toLocaleString()}
unit="tokens"
/>
)}
{aiData.reasoningTokens != null && aiData.reasoningTokens > 0 && (
<MetricRow
label="Reasoning"
value={aiData.reasoningTokens.toLocaleString()}
unit="tokens"
/>
)}
<MetricRow label="Total" value={aiData.totalTokens.toLocaleString()} unit="tokens" bold />
{aiData.totalCost != null && (
<MetricRow label="Cost" value={formatCurrencyAccurate(aiData.totalCost)} />
)}
{aiData.msToFirstChunk != null && (
<MetricRow label="TTFC" value={formatTtfc(aiData.msToFirstChunk)} />
)}
{aiData.tokensPerSecond != null && (
<MetricRow label="Speed" value={`${Math.round(aiData.tokensPerSecond)} tok/s`} />
)}
</div>
</div>
);
}
function MetricRow({
label,
value,
unit,
bold,
}: {
label: string;
value: ReactNode;
unit?: string;
bold?: boolean;
}) {
return (
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
<span className="text-text-dimmed">{label}</span>
<span
className={`text-right @[28rem]:text-left ${
bold ? "font-medium text-text-bright" : "text-text-bright"
}`}
>
{value}
{unit && <span className="ml-1 text-text-dimmed">{unit}</span>}
</span>
</div>
);
}
function formatTtfc(ms: number): string {
if (ms >= 10_000) {
return `${(ms / 1000).toFixed(1)}s`;
}
return `${Math.round(ms)}ms`;
}
@@ -0,0 +1,351 @@
import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid";
import { lazy, Suspense, useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { tryPrettyJson } from "./aiHelpers";
import { SpanMetricRow as PromptMetricRow } from "./SpanMetricRow";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { v3PromptPath } from "~/utils/pathBuilder";
import { CodeBlock } from "~/components/code/CodeBlock";
import { AIChatMessages, AssistantResponse, ChatBubble } from "./AIChatMessages";
import type { PromptLink } from "./AIChatMessages";
import { AIStatsSummary, AITagsRow } from "./AIModelSummary";
import { AIToolsInventory } from "./AIToolsInventory";
import type { AISpanData, DisplayItem } from "./types";
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children }: { children: string }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
type AITab = "overview" | "messages" | "tools" | "prompt";
export function AISpanDetails({
aiData,
promptVersionData,
rawProperties,
}: {
aiData: AISpanData;
promptVersionData?: PromptSpanData;
rawProperties?: string;
}) {
const [tab, setTab] = useState<AITab>("overview");
const isAdmin = useHasAdminAccess();
const toolCount = aiData.toolCount ?? aiData.toolDefinitions?.length ?? 0;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const promptLink: PromptLink | undefined =
aiData.promptSlug && organization && project && environment
? {
slug: aiData.promptSlug,
version: aiData.promptVersion,
path: v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion),
}
: undefined;
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Tab bar */}
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<TabContainer>
<TabButton
isActive={tab === "overview"}
layoutId="ai-span"
onClick={() => setTab("overview")}
shortcut={{ key: "o" }}
>
Overview
</TabButton>
<TabButton
isActive={tab === "messages"}
layoutId="ai-span"
onClick={() => setTab("messages")}
shortcut={{ key: "m" }}
>
Messages
</TabButton>
<TabButton
isActive={tab === "tools"}
layoutId="ai-span"
onClick={() => setTab("tools")}
shortcut={{ key: "t" }}
>
<span className="inline-flex items-center whitespace-nowrap">
Tools
{toolCount > 0 && (
<span className="ml-1 inline-flex min-w-4 -translate-y-px items-center justify-center rounded-full border border-charcoal-600 bg-charcoal-650 px-1 py-0.5 text-[0.625rem] font-medium leading-none text-text-bright">
{toolCount}
</span>
)}
</span>
</TabButton>
{promptVersionData && (
<TabButton
isActive={tab === "prompt"}
layoutId="ai-span"
onClick={() => setTab("prompt")}
shortcut={{ key: "p" }}
>
Prompt
</TabButton>
)}
</TabContainer>
</div>
{/* Tab content */}
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{tab === "overview" && <OverviewTab aiData={aiData} />}
{tab === "messages" && <MessagesTab aiData={aiData} promptLink={promptLink} />}
{tab === "tools" && <ToolsTab aiData={aiData} />}
{tab === "prompt" && promptVersionData && (
<PromptTab promptData={promptVersionData} promptLink={promptLink} />
)}
</div>
{/* Footer: Copy raw (admin only) */}
{isAdmin && rawProperties && <CopyRawFooter rawProperties={rawProperties} />}
</div>
);
}
function OverviewTab({ aiData }: { aiData: AISpanData }) {
const { userText, outputText, outputObject, outputToolNames } = extractInputOutput(aiData);
return (
<div className="flex flex-col px-3">
<AITagsRow aiData={aiData} />
<AIStatsSummary aiData={aiData} />
{userText && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<ChatBubble>
<Paragraph variant="small/dimmed">{userText}</Paragraph>
</ChatBubble>
</div>
)}
{outputText && <AssistantResponse text={outputText} headerLabel="Output" />}
{!outputText && outputObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Output</Header3>
<CodeBlock
code={outputObject}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{outputToolNames.length > 0 && !outputText && !outputObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Output</Header3>
<ChatBubble>
<Paragraph variant="small/dimmed">
Called {outputToolNames.length === 1 ? "tool" : "tools"}:{" "}
<span className="font-mono text-text-bright">{outputToolNames.join(", ")}</span>
</Paragraph>
</ChatBubble>
</div>
)}
</div>
);
}
function MessagesTab({
aiData,
promptLink,
}: {
aiData: AISpanData;
promptLink?: PromptLink;
}) {
const showFallbackText = aiData.responseText && !hasAssistantItem(aiData.items);
const showFallbackObject =
!showFallbackText && aiData.responseObject && !hasAssistantItem(aiData.items);
return (
<div className="px-3">
<div className="flex flex-col">
{aiData.items && aiData.items.length > 0 && (
<AIChatMessages items={aiData.items} promptLink={promptLink} />
)}
{showFallbackText && <AssistantResponse text={aiData.responseText!} />}
{showFallbackObject && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Assistant</Header3>
<CodeBlock
code={aiData.responseObject!}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
</div>
</div>
);
}
function ToolsTab({ aiData }: { aiData: AISpanData }) {
return <AIToolsInventory aiData={aiData} />;
}
function CopyRawFooter({ rawProperties }: { rawProperties: string }) {
const [copied, setCopied] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(rawProperties);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex h-[3.25rem] shrink-0 items-center justify-end border-t border-grid-dimmed px-2">
<Button
variant="minimal/medium"
onClick={handleCopy}
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
leadingIconClassName={copied ? "text-green-500" : undefined}
>
Copy raw properties
</Button>
</div>
);
}
function PromptTab({
promptData,
promptLink,
}: {
promptData: PromptSpanData;
promptLink?: PromptLink;
}) {
const labels = promptData.labels
? promptData.labels.split(",").map((l) => l.trim()).filter(Boolean)
: [];
return (
<div className="flex flex-col px-3">
{/* Prompt properties */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<PromptMetricRow
label="Prompt"
value={
promptLink ? (
<TextLink to={promptLink.path}>{promptData.slug}</TextLink>
) : (
promptData.slug
)
}
/>
<PromptMetricRow label="Version" value={`v${promptData.version}`} />
{labels.length > 0 && <PromptMetricRow label="Labels" value={labels.join(", ")} />}
{promptData.model && <PromptMetricRow label="Model" value={promptData.model} />}
</div>
</div>
{/* Prompt input (variables passed to resolve()) */}
{promptData.input && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<CodeBlock
code={tryPrettyJson(promptData.input)}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
{/* Template (from the prompt version) */}
{promptData.template && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Template</Header3>
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
<Suspense
fallback={<span className="whitespace-pre-wrap">{promptData.template}</span>}
>
<StreamdownRenderer>{promptData.template}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function extractInputOutput(aiData: AISpanData): {
userText: string | undefined;
outputText: string | undefined;
outputObject: string | undefined;
outputToolNames: string[];
} {
let userText: string | undefined;
let outputText: string | undefined;
const outputToolNames: string[] = [];
if (aiData.items) {
for (let i = aiData.items.length - 1; i >= 0; i--) {
if (aiData.items[i].type === "user") {
userText = (aiData.items[i] as { type: "user"; text: string }).text;
break;
}
}
for (let i = aiData.items.length - 1; i >= 0; i--) {
const item = aiData.items[i];
if (item.type === "assistant") {
outputText = item.text;
break;
}
if (item.type === "tool-use") {
for (const tool of item.tools) {
outputToolNames.push(tool.toolName);
}
break;
}
}
}
if (!outputText && aiData.responseText) {
outputText = aiData.responseText;
}
return {
userText,
outputText,
outputObject: aiData.responseObject ? tryPrettyJson(aiData.responseObject) : undefined,
outputToolNames,
};
}
function hasAssistantItem(items: DisplayItem[] | undefined): boolean {
if (!items) return false;
return items.some((item) => item.type === "assistant");
}
@@ -0,0 +1,76 @@
import { Header3 } from "~/components/primitives/Headers";
import { CodeBlock } from "~/components/code/CodeBlock";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { formatDuration, tryPrettyJson } from "./aiHelpers";
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
export type AIToolCallData = {
toolName: string;
toolCallId: string;
args?: string;
durationMs: number;
};
export function extractAIToolCallData(
properties: Record<string, unknown>,
durationMs: number
): AIToolCallData | undefined {
const ai = properties.ai;
if (!ai || typeof ai !== "object") return undefined;
const a = ai as Record<string, unknown>;
if (a.operationId !== "ai.toolCall") return undefined;
const toolCall = a.toolCall;
if (!toolCall || typeof toolCall !== "object") return undefined;
const tc = toolCall as Record<string, unknown>;
const toolName = typeof tc.name === "string" ? tc.name : undefined;
if (!toolName) return undefined;
return {
toolName,
toolCallId: typeof tc.id === "string" ? tc.id : "",
args: typeof tc.args === "string" ? tc.args : undefined,
durationMs,
};
}
export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col px-3">
{/* Tool info */}
<div className="flex flex-col gap-1 py-2.5">
<div className="flex flex-col text-xs @container">
<MetricRow label="Tool" value={data.toolName} />
{data.toolCallId && (
<MetricRow
label="Call ID"
value={<TruncatedCopyableValue value={data.toolCallId} />}
/>
)}
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
</div>
</div>
{/* Input args */}
{data.args && (
<div className="flex flex-col gap-1.5 py-2.5">
<Header3>Input</Header3>
<CodeBlock
code={tryPrettyJson(data.args)}
maxLines={20}
showLineNumbers={false}
showCopyButton
language="json"
/>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
import { CodeBlock } from "~/components/code/CodeBlock";
import type { AISpanData, ToolDefinition } from "./types";
import { Paragraph } from "~/components/primitives/Paragraph";
export function AIToolsInventory({ aiData }: { aiData: AISpanData }) {
const defs = aiData.toolDefinitions ?? [];
const calledNames = getCalledToolNames(aiData);
if (defs.length === 0) {
return (
<div className="px-3 py-6 text-center">
<Paragraph variant="small/dimmed">No tool definitions available for this span.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col divide-y divide-grid-bright px-3">
{defs.map((def) => {
const wasCalled = calledNames.has(def.name);
return <ToolDefRow key={def.name} def={def} wasCalled={wasCalled} />;
})}
</div>
);
}
function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolean }) {
const [showSchema, setShowSchema] = useState(false);
return (
<div className="flex flex-col gap-1.5 py-2.5">
<div className="flex items-center gap-2">
<div
className={`size-1.5 shrink-0 rounded-full ${
wasCalled ? "bg-success" : "bg-charcoal-600"
}`}
/>
<code className="font-mono text-xs text-text-bright">{def.name}</code>
<span className="text-[10px] text-text-dimmed">{wasCalled ? "called" : "not called"}</span>
</div>
{def.description && (
<p className="pl-3.5 text-xs leading-relaxed text-text-dimmed">{def.description}</p>
)}
{def.parametersJson && (
<div className="pl-3.5">
<button
onClick={() => setShowSchema(!showSchema)}
className="text-[10px] text-text-link hover:underline"
>
{showSchema ? "Hide schema" : "Show schema"}
</button>
{showSchema && (
<div className="mt-1">
<CodeBlock
code={def.parametersJson}
maxLines={16}
showLineNumbers={false}
showCopyButton
/>
</div>
)}
</div>
)}
</div>
);
}
function getCalledToolNames(aiData: AISpanData): Set<string> {
const names = new Set<string>();
if (!aiData.items) return names;
for (const item of aiData.items) {
if (item.type === "tool-use") {
for (const tool of item.tools) {
names.add(tool.toolName);
}
}
}
return names;
}
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
export function SpanMetricRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
<span className="text-text-dimmed">{label}</span>
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
</div>
);
}
@@ -0,0 +1,100 @@
// Shared primitive helpers for AI span data extraction
export function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
}
export function str(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
export function num(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
export function tryPrettyJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const mins = Math.floor(ms / 60_000);
const secs = ((ms % 60_000) / 1000).toFixed(0);
return `${mins}m ${secs}s`;
}
/**
* Parse provider metadata from a JSON string.
* Handles Anthropic, Azure, OpenAI, Gateway, and OpenRouter formats.
*/
export function parseProviderMetadata(
raw: unknown
): {
serviceTier?: string;
resolvedProvider?: string;
gatewayCost?: string;
responseId?: string;
} | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return undefined;
let serviceTier: string | undefined;
let resolvedProvider: string | undefined;
let gatewayCost: string | undefined;
let responseId: string | undefined;
// Anthropic: { anthropic: { usage: { service_tier: "standard" } } }
const anthropic = rec(parsed.anthropic);
serviceTier = str(rec(anthropic.usage).service_tier);
// Azure/OpenAI: { azure: { serviceTier: "default" } } or { openai: { serviceTier: "..." } }
const openai = rec(parsed.openai);
if (!serviceTier) {
serviceTier = str(rec(parsed.azure).serviceTier) ?? str(openai.serviceTier);
}
// OpenAI response ID
responseId = str(openai.responseId);
// Gateway: { gateway: { routing: { finalProvider, resolvedProvider }, cost } }
const gateway = rec(parsed.gateway);
const routing = rec(gateway.routing);
resolvedProvider = str(routing.finalProvider) ?? str(routing.resolvedProvider);
gatewayCost = str(gateway.cost);
// OpenRouter: { openrouter: { provider: "xAI" } }
if (!resolvedProvider) {
resolvedProvider = str(rec(parsed.openrouter).provider);
}
if (!serviceTier && !resolvedProvider && !gatewayCost && !responseId) return undefined;
return { serviceTier, resolvedProvider, gatewayCost, responseId };
} catch {
return undefined;
}
}
/**
* Extract user-defined telemetry metadata, coercing non-string values.
* Skips the "prompt" key which is handled separately.
*/
export function extractTelemetryMetadata(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== "object") return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (key === "prompt") continue;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
result[key] = String(value);
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
@@ -0,0 +1,452 @@
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
import type { AISpanData, DisplayItem, ToolDefinition, ToolUse } from "./types";
/**
* Extracts structured AI span data from unflattened OTEL span properties.
*
* Works with the nested object produced by `unflattenAttributes()` — expects
* keys like `gen_ai.response.model`, `ai.prompt.messages`, `trigger.llm.total_cost`, etc.
*
* @param properties Unflattened span properties object
* @param durationMs Span duration in milliseconds
* @returns Structured AI data, or undefined if this isn't an AI generation span
*/
export function extractAISpanData(
properties: Record<string, unknown>,
durationMs: number
): AISpanData | undefined {
const genAi = properties.gen_ai;
if (!genAi || typeof genAi !== "object") return undefined;
const g = genAi as Record<string, unknown>;
const ai = rec(properties.ai);
const trigger = rec(properties.trigger);
const gResponse = rec(g.response);
const gRequest = rec(g.request);
const gUsage = rec(g.usage);
const gOperation = rec(g.operation);
const aiModel = rec(ai.model);
const aiResponse = rec(ai.response);
const aiPrompt = rec(ai.prompt);
const aiUsage = rec(ai.usage);
const triggerLlm = rec(trigger.llm);
const model = str(gResponse.model) ?? str(gRequest.model) ?? str(aiModel.id);
if (!model) return undefined;
// Prefer ai.usage (richer) over gen_ai.usage.
// Gateway/some providers emit promptTokens/completionTokens instead of inputTokens/outputTokens.
const inputTokens =
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? num(gUsage.input_tokens) ?? 0;
const outputTokens =
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? num(gUsage.output_tokens) ?? 0;
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
const tokensPerSecond =
num(aiResponse.avgOutputTokensPerSecond) ??
(outputTokens > 0 && durationMs > 0
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
: undefined);
const toolDefs = parseToolDefinitions(aiPrompt.tools);
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
const aiTelemetry = rec(ai.telemetry);
const telemetryMetaRaw = rec(aiTelemetry.metadata);
const promptMeta = rec(telemetryMetaRaw.prompt);
const promptSlug = str(promptMeta.slug);
const promptVersion = str(promptMeta.version);
const promptModel = str(promptMeta.model);
const promptLabels = str(promptMeta.labels);
const promptInput = str(promptMeta.input);
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
return {
model,
provider: str(g.system) ?? "unknown",
operationName: str(gOperation.name) ?? str(ai.operationId) ?? "",
responseId: str(gResponse.id) || undefined,
finishReason: str(aiResponse.finishReason),
serviceTier: providerMeta?.serviceTier,
resolvedProvider: providerMeta?.resolvedProvider,
toolChoice: parseToolChoice(aiPrompt.toolChoice),
toolCount: toolDefs?.length,
messageCount: countMessages(aiPrompt.messages),
telemetryMetadata: telemetryMeta,
promptSlug: promptSlug || undefined,
promptVersion: promptVersion || undefined,
promptModel: promptModel || undefined,
promptLabels: promptLabels || undefined,
promptInput: promptInput || undefined,
inputTokens,
outputTokens,
totalTokens,
cachedTokens: num(aiUsage.cachedInputTokens) ?? num(gUsage.cache_read_input_tokens),
cacheCreationTokens:
num(aiUsage.cacheCreationInputTokens) ?? num(gUsage.cache_creation_input_tokens),
reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens),
tokensPerSecond,
msToFirstChunk: num(aiResponse.msToFirstChunk),
durationMs,
inputCost: num(triggerLlm.input_cost),
outputCost: num(triggerLlm.output_cost),
totalCost: num(triggerLlm.total_cost),
responseText: str(aiResponse.text) || undefined,
responseObject: str(aiResponse.object) || undefined,
toolDefinitions: toolDefs,
items: buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs),
};
}
// ---------------------------------------------------------------------------
// Message → DisplayItem transformation
// ---------------------------------------------------------------------------
type RawMessage = {
role: string;
content: unknown;
toolCallId?: string;
name?: string;
};
/**
* Build display items from prompt messages and optionally response tool calls.
* - Parses ai.prompt.messages and merges consecutive tool-call + tool-result pairs
* - If ai.response.toolCalls is present (finishReason=tool-calls), appends those too
*/
function buildDisplayItems(
messagesRaw: unknown,
responseToolCallsRaw: unknown,
toolDefs?: ToolDefinition[]
): DisplayItem[] | undefined {
const items = parseMessagesToDisplayItems(messagesRaw);
const responseToolCalls = parseResponseToolCalls(responseToolCallsRaw);
if (!items && !responseToolCalls) return undefined;
const result = items ?? [];
if (responseToolCalls && responseToolCalls.length > 0) {
result.push({ type: "tool-use", tools: responseToolCalls });
}
if (toolDefs && toolDefs.length > 0) {
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
for (const item of result) {
if (item.type === "tool-use") {
for (const tool of item.tools) {
const def = defsByName.get(tool.toolName);
if (def) {
tool.description = def.description;
tool.parametersJson = def.parametersJson;
}
}
}
}
}
return result.length > 0 ? result : undefined;
}
function parseMessagesToDisplayItems(raw: unknown): DisplayItem[] | undefined {
if (typeof raw !== "string") return undefined;
let messages: RawMessage[];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
messages = parsed.map((item: unknown) => {
const m = rec(item);
return {
role: str(m.role) ?? "user",
content: m.content,
toolCallId: str(m.toolCallId),
name: str(m.name),
};
});
} catch {
return undefined;
}
const items: DisplayItem[] = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role === "system") {
items.push({ type: "system", text: extractTextContent(msg.content) });
i++;
continue;
}
if (msg.role === "user") {
items.push({ type: "user", text: extractTextContent(msg.content) });
i++;
continue;
}
// Assistant message — check if it contains tool calls
if (msg.role === "assistant") {
const toolCalls = extractToolCalls(msg.content);
if (toolCalls.length > 0) {
// Collect subsequent tool result messages that match these tool calls
const toolCallIds = new Set(toolCalls.map((tc) => tc.toolCallId));
let j = i + 1;
while (j < messages.length && messages[j].role === "tool") {
j++;
}
// Gather tool result messages between i+1 and j
const toolResultMsgs = messages.slice(i + 1, j);
// Build ToolUse entries by pairing calls with results
const tools: ToolUse[] = toolCalls.map((tc) => {
const resultMsg = toolResultMsgs.find((m) => {
// Match by toolCallId in the message's content parts
const results = extractToolResults(m.content);
return results.some((r) => r.toolCallId === tc.toolCallId);
});
const result = resultMsg
? extractToolResults(resultMsg.content).find(
(r) => r.toolCallId === tc.toolCallId
)
: undefined;
return {
toolCallId: tc.toolCallId,
toolName: tc.toolName,
inputJson: JSON.stringify(tc.input, null, 2),
resultSummary: result?.summary,
resultOutput: result?.formattedOutput,
};
});
items.push({ type: "tool-use", tools });
i = j; // skip past the tool result messages
continue;
}
// Assistant message with just text
const text = extractTextContent(msg.content);
if (text) {
items.push({ type: "assistant", text });
}
i++;
continue;
}
// Skip any other message types (tool messages that weren't consumed above)
i++;
}
return items.length > 0 ? items : undefined;
}
// ---------------------------------------------------------------------------
// Response tool calls (from ai.response.toolCalls, used when finishReason=tool-calls)
// ---------------------------------------------------------------------------
/**
* Parse ai.response.toolCalls JSON string into ToolUse entries.
* These are tool calls the model requested but haven't been executed yet in this span.
*/
function parseResponseToolCalls(raw: unknown): ToolUse[] | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const tools: ToolUse[] = [];
for (const item of parsed) {
const tc = rec(item);
if (tc.type === "tool-call" || tc.toolName || tc.toolCallId) {
tools.push({
toolCallId: str(tc.toolCallId) ?? "",
toolName: str(tc.toolName) ?? "",
inputJson: JSON.stringify(
tc.input && typeof tc.input === "object" ? tc.input : {},
null,
2
),
});
}
}
return tools.length > 0 ? tools : undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Content part extraction
// ---------------------------------------------------------------------------
function extractTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const texts: string[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "text" && typeof p.text === "string") {
texts.push(p.text);
} else if (typeof p.text === "string") {
texts.push(p.text);
}
}
return texts.join("\n");
}
type ParsedToolCall = {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
};
function extractToolCalls(content: unknown): ParsedToolCall[] {
if (!Array.isArray(content)) return [];
const calls: ParsedToolCall[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "tool-call") {
calls.push({
toolCallId: str(p.toolCallId) ?? "",
toolName: str(p.toolName) ?? "",
input: p.input && typeof p.input === "object" ? (p.input as Record<string, unknown>) : {},
});
}
}
return calls;
}
type ParsedToolResult = {
toolCallId: string;
toolName: string;
summary: string;
formattedOutput: string;
};
function extractToolResults(content: unknown): ParsedToolResult[] {
if (!Array.isArray(content)) return [];
const results: ParsedToolResult[] = [];
for (const raw of content) {
const p = rec(raw);
if (p.type === "tool-result") {
const { summary, formattedOutput } = summarizeToolOutput(p.output);
results.push({
toolCallId: str(p.toolCallId) ?? "",
toolName: str(p.toolName) ?? "",
summary,
formattedOutput,
});
}
}
return results;
}
/**
* Summarize a tool output into a short label and a formatted string for display.
* Handles the AI SDK's `{ type: "json", value: { status, contentType, body, truncated } }` shape.
*/
function summarizeToolOutput(output: unknown): { summary: string; formattedOutput: string } {
if (typeof output === "string") {
return {
summary: output.length > 80 ? output.slice(0, 80) + "..." : output,
formattedOutput: output,
};
}
if (!output || typeof output !== "object") {
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
}
const o = output as Record<string, unknown>;
// AI SDK wraps tool results as { type: "json", value: { status, contentType, body, ... } }
if (o.type === "json" && o.value && typeof o.value === "object") {
const v = o.value as Record<string, unknown>;
const parts: string[] = [];
if (typeof v.status === "number") parts.push(`${v.status}`);
if (typeof v.contentType === "string") parts.push(v.contentType);
if (v.truncated === true) parts.push("truncated");
return {
summary: parts.length > 0 ? parts.join(" · ") : "json result",
formattedOutput: JSON.stringify(v, null, 2),
};
}
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
}
// ---------------------------------------------------------------------------
// Tool definitions (from ai.prompt.tools)
// ---------------------------------------------------------------------------
/**
* Parse ai.prompt.tools — after the array fix, this arrives as a JSON array string
* where each element is itself a JSON string of a tool definition.
*/
function parseToolDefinitions(raw: unknown): ToolDefinition[] | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
const defs: ToolDefinition[] = [];
for (const item of parsed) {
// Each item is either a JSON string or already an object
const obj = typeof item === "string" ? JSON.parse(item) : item;
if (!obj || typeof obj !== "object") continue;
const o = obj as Record<string, unknown>;
const name = str(o.name);
if (!name) continue;
const schema = o.parameters ?? o.inputSchema;
defs.push({
name,
description: str(o.description),
parametersJson:
schema && typeof schema === "object"
? JSON.stringify(schema, null, 2)
: undefined,
});
}
return defs.length > 0 ? defs : undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Tool choice parsing
// ---------------------------------------------------------------------------
function parseToolChoice(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "string") return parsed;
if (parsed && typeof parsed === "object") {
const obj = parsed as Record<string, unknown>;
if (typeof obj.type === "string") return obj.type;
}
return undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// Message count
// ---------------------------------------------------------------------------
function countMessages(raw: unknown): number | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return undefined;
return parsed.length > 0 ? parsed.length : undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,195 @@
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
import type { AISpanData, DisplayItem } from "./types";
/**
* Extracts structured AI data from top-level AI SDK parent spans.
*
* These spans (ai.generateText, ai.streamText, ai.generateObject, ai.streamObject)
* use `ai.*` attributes instead of `gen_ai.*`. They contain the full prompt,
* aggregated response, and total usage across all steps.
*/
export function extractAISummarySpanData(
properties: Record<string, unknown>,
durationMs: number
): AISpanData | undefined {
const ai = rec(properties.ai);
if (!ai.operationId) return undefined;
// Skip child spans that have gen_ai.* (those use extractAISpanData)
if (properties.gen_ai && typeof properties.gen_ai === "object") return undefined;
const aiModel = rec(ai.model);
const aiResponse = rec(ai.response);
const aiUsage = rec(ai.usage);
const aiSettings = rec(ai.settings);
const aiRequest = rec(ai.request);
const aiTelemetry = rec(ai.telemetry);
const trigger = rec(properties.trigger);
const triggerLlm = rec(trigger.llm);
const model = str(aiModel.id);
if (!model) return undefined;
const provider = str(aiModel.provider) ?? "unknown";
const operationName = str(ai.operationId) ?? "";
// Token usage
const inputTokens =
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? 0;
const outputTokens =
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? 0;
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
const tokensPerSecond =
outputTokens > 0 && durationMs > 0
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
: undefined;
// Provider metadata
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
// Response ID from provider metadata
const responseId = providerMeta?.responseId;
// Telemetry metadata (prompt info, custom metadata)
const telemetryMetaRaw = rec(aiTelemetry.metadata);
const promptMeta = rec(telemetryMetaRaw.prompt);
const promptSlug = str(promptMeta.slug);
const promptVersion = str(promptMeta.version);
const promptModel = str(promptMeta.model);
const promptLabels = str(promptMeta.labels);
const promptInput = str(promptMeta.input);
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
// Parse the prompt JSON to build display items
const promptJson = str(ai.prompt);
const items = promptJson ? parsePromptToDisplayItems(promptJson, str(aiResponse.text)) : undefined;
// Count messages from the parsed prompt
let messageCount: number | undefined;
if (promptJson) {
try {
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
if (parsed.messages && Array.isArray(parsed.messages)) {
messageCount = parsed.messages.length;
} else {
// system + prompt = 2 messages
messageCount = (parsed.system ? 1 : 0) + (parsed.prompt ? 1 : 0);
}
} catch {}
}
return {
model,
provider,
operationName,
responseId,
finishReason: str(aiResponse.finishReason),
serviceTier: providerMeta?.serviceTier,
resolvedProvider: providerMeta?.resolvedProvider,
toolChoice: undefined,
toolCount: undefined,
messageCount,
telemetryMetadata: telemetryMeta,
promptSlug: promptSlug || undefined,
promptVersion: promptVersion || undefined,
promptModel: promptModel || undefined,
promptLabels: promptLabels || undefined,
promptInput: promptInput || undefined,
inputTokens,
outputTokens,
totalTokens,
cachedTokens: num(aiUsage.cachedInputTokens),
cacheCreationTokens: num(aiUsage.cacheCreationInputTokens),
reasoningTokens: num(aiUsage.reasoningTokens),
tokensPerSecond,
msToFirstChunk: undefined, // Only on child doStream spans
durationMs,
inputCost: num(triggerLlm.input_cost),
outputCost: num(triggerLlm.output_cost),
totalCost: num(triggerLlm.total_cost),
responseText: str(aiResponse.text) || undefined,
responseObject: str(aiResponse.object) || undefined,
toolDefinitions: undefined,
items,
};
}
// ---------------------------------------------------------------------------
// Prompt parsing
// ---------------------------------------------------------------------------
/**
* Parses the `ai.prompt` JSON string into display items.
* Parent spans store the prompt as a JSON object with either:
* - { system: "...", prompt: "..." }
* - { system: "...", messages: [...] }
* - { messages: [...] }
*/
function parsePromptToDisplayItems(
promptJson: string,
responseText?: string
): DisplayItem[] | undefined {
try {
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return undefined;
const items: DisplayItem[] = [];
if (typeof parsed.system === "string" && parsed.system) {
items.push({ type: "system", text: parsed.system });
}
if (typeof parsed.prompt === "string" && parsed.prompt) {
items.push({ type: "user", text: parsed.prompt });
}
if (Array.isArray(parsed.messages)) {
for (const msg of parsed.messages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
const role = m.role;
const content = extractMessageContent(m.content);
if (!content) continue;
switch (role) {
case "system":
items.push({ type: "system", text: content });
break;
case "user":
items.push({ type: "user", text: content });
break;
case "assistant":
items.push({ type: "assistant", text: content });
break;
}
}
}
// Add response as assistant item if not already present
if (responseText && !items.some((i) => i.type === "assistant")) {
items.push({ type: "assistant", text: responseText });
}
return items.length > 0 ? items : undefined;
} catch {
return undefined;
}
}
function extractMessageContent(content: unknown): string | undefined {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
// Extract text parts from content array [{type: "text", text: "..."}]
return content
.filter((p): p is { type: string; text: string } => {
if (!p || typeof p !== "object") return false;
const o = p as Record<string, unknown>;
return o.type === "text" && typeof o.text === "string";
})
.map((p) => p.text)
.join("\n");
}
return undefined;
}
@@ -0,0 +1,8 @@
export { AISpanDetails } from "./AISpanDetails";
export { extractAISpanData } from "./extractAISpanData";
export { extractAISummarySpanData } from "./extractAISummarySpanData";
export { AIToolCallSpanDetails, extractAIToolCallData } from "./AIToolCallSpanDetails";
export type { AIToolCallData } from "./AIToolCallSpanDetails";
export { AIEmbedSpanDetails, extractAIEmbedData } from "./AIEmbedSpanDetails";
export type { AIEmbedData } from "./AIEmbedSpanDetails";
export type { AISpanData, DisplayItem, ToolUse } from "./types";
@@ -0,0 +1,113 @@
// ---------------------------------------------------------------------------
// Tool use (merged assistant tool-call + tool result)
// ---------------------------------------------------------------------------
export type ToolDefinition = {
name: string;
description?: string;
/** JSON schema as formatted string */
parametersJson?: string;
};
export type ToolUse = {
toolCallId: string;
toolName: string;
/** Tool description from the definition, if available */
description?: string;
/** JSON schema of the tool's parameters, pretty-printed */
parametersJson?: string;
/** Formatted input args as JSON string */
inputJson: string;
/** Short summary of the result (e.g. "200 · text/html · truncated") */
resultSummary?: string;
/** Full formatted result for display in a code block */
resultOutput?: string;
};
// ---------------------------------------------------------------------------
// Display items — what the UI actually renders
// ---------------------------------------------------------------------------
/** System prompt text (collapsible) */
export type SystemItem = {
type: "system";
text: string;
};
/** User message text */
export type UserItem = {
type: "user";
text: string;
};
/** One or more tool calls with their results, grouped */
export type ToolUseItem = {
type: "tool-use";
tools: ToolUse[];
};
/** Final assistant text response */
export type AssistantItem = {
type: "assistant";
text: string;
};
export type DisplayItem = SystemItem | UserItem | ToolUseItem | AssistantItem;
// ---------------------------------------------------------------------------
// Span-level AI data
// ---------------------------------------------------------------------------
export type AISpanData = {
model: string;
provider: string;
operationName: string;
responseId?: string;
// Categorical tags
finishReason?: string;
serviceTier?: string;
/** Resolved downstream provider for gateway/openrouter spans (e.g. "xAI", "mistral") */
resolvedProvider?: string;
toolChoice?: string;
toolCount?: number;
messageCount?: number;
/** User-defined telemetry metadata (from ai.telemetry.metadata) */
telemetryMetadata?: Record<string, string>;
// Prompt metadata (from ai.telemetry.metadata.prompt)
promptSlug?: string;
promptVersion?: string;
promptModel?: string;
promptLabels?: string;
promptInput?: string;
// Token counts
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
// Performance
tokensPerSecond?: number;
msToFirstChunk?: number;
durationMs: number;
// Cost
inputCost?: number;
outputCost?: number;
totalCost?: number;
// Response text (final assistant output)
responseText?: string;
// Structured object response (JSON) — mutually exclusive with responseText
responseObject?: string;
// Tool definitions (from ai.prompt.tools)
toolDefinitions?: ToolDefinition[];
// Display-ready message items (system, user, tool-use groups, assistant text)
items?: DisplayItem[];
};
+51 -1
View File
@@ -39,6 +39,7 @@ const S2EnvSchema = z.preprocess(
S2_ENABLED: z.literal("1"),
S2_ACCESS_TOKEN: z.string(),
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
}),
z.object({
S2_ENABLED: z.literal("0"),
@@ -372,6 +373,7 @@ const EnvironmentSchema = z
// Development OTEL environment variables
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),
// If this is set to 1, then the below variables are used to configure the batch processor for spans and logs
DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
@@ -382,6 +384,9 @@ const EnvironmentSchema = z
DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
DEV_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
DEV_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
DEV_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
@@ -392,6 +397,9 @@ const EnvironmentSchema = z
PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
PROD_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
PROD_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
PROD_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
@@ -540,6 +548,9 @@ const EnvironmentSchema = z
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
QUEUE_SIZE_CACHE_TTL_MS: z.coerce.number().int().optional().default(1_000), // 1 second
QUEUE_SIZE_CACHE_MAX_SIZE: z.coerce.number().int().optional().default(5_000),
QUEUE_SIZE_CACHE_ENABLED: z.coerce.number().int().optional().default(1),
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
@@ -549,7 +560,7 @@ const EnvironmentSchema = z
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(5),
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
@@ -596,6 +607,20 @@ const EnvironmentSchema = z
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
// TTL System settings for automatic run expiration
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),
RUN_ENGINE_TTL_SYSTEM_SHARD_COUNT: z.coerce.number().int().optional(),
RUN_ENGINE_TTL_SYSTEM_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
RUN_ENGINE_TTL_SYSTEM_BATCH_SIZE: z.coerce.number().int().default(100),
RUN_ENGINE_TTL_WORKER_CONCURRENCY: z.coerce.number().int().default(1),
RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE: z.coerce.number().int().default(50),
RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false),
RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000),
/** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL
* will use this as their TTL, and runs with a TTL larger than this will be clamped. */
RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(),
RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000),
RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000),
RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10),
@@ -968,6 +993,9 @@ const EnvironmentSchema = z
// Global rate limit: max items processed per second across all consumers
// If not set, no global rate limiting is applied
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
// Max items in the worker queue before claiming pauses (protects visibility timeouts)
// If not set, no depth limit is applied
BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH: z.coerce.number().int().positive().optional(),
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
@@ -1171,6 +1199,7 @@ const EnvironmentSchema = z
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
// Clickhouse
CLICKHOUSE_URL: z.string(),
@@ -1218,6 +1247,12 @@ const EnvironmentSchema = z
// Metric widget concurrency limits
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
// Admin ClickHouse URL (for admin dashboard queries like missing models)
ADMIN_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
@@ -1229,6 +1264,9 @@ const EnvironmentSchema = z
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_MAX_CONCURRENCY: z.coerce.number().int().default(3),
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
@@ -1245,6 +1283,16 @@ const EnvironmentSchema = z
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
// LLM cost tracking
LLM_COST_TRACKING_ENABLED: BoolEnv.default(true),
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce.number().int().default(5 * 60 * 1000), // 5 minutes
LLM_PRICING_SEED_ON_STARTUP: BoolEnv.default(false),
LLM_PRICING_READY_TIMEOUT_MS: z.coerce.number().int().default(500),
LLM_METRICS_BATCH_SIZE: z.coerce.number().int().default(5000),
LLM_METRICS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(2000),
LLM_METRICS_MAX_BATCH_SIZE: z.coerce.number().int().default(10000),
LLM_METRICS_MAX_CONCURRENCY: z.coerce.number().int().default(2),
// Bootstrap
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
@@ -1317,6 +1365,8 @@ const EnvironmentSchema = z
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
.number()
.int()
+24
View File
@@ -0,0 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { extractDomain, faviconUrl } from "~/utils/favicon";
function resolve(input: string, size: number): string | null {
const domain = extractDomain(input);
return domain && domain.includes(".") ? faviconUrl(domain, size) : null;
}
export function useFaviconUrl(urlInput: string, size: number = 64) {
const [url, setUrl] = useState<string | null>(() => resolve(urlInput, size));
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setUrl(resolve(urlInput, size));
}, 400);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [urlInput, size]);
return url;
}
+15 -9
View File
@@ -8,15 +8,13 @@ import {
getImpersonationId,
setImpersonationId,
} from "~/services/impersonation.server";
import { authenticator } from "~/services/auth.server";
import { requireUser } from "~/services/session.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
const pageSize = 20;
export async function adminGetUsers(
userId: string,
{ page, search }: SearchParams,
) {
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
page = page || 1;
search = search ? decodeURIComponent(search) : undefined;
@@ -231,7 +229,11 @@ export async function redirectWithImpersonation(request: Request, userId: string
},
});
} catch (error) {
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId: userId });
logger.error("Failed to create impersonation audit log", {
error,
adminId: user.id,
targetId: userId,
});
}
const session = await setImpersonationId(userId, request);
@@ -242,10 +244,10 @@ export async function redirectWithImpersonation(request: Request, userId: string
}
export async function clearImpersonation(request: Request, path: string) {
const user = await requireUser(request);
const authUser = await authenticator.isAuthenticated(request);
const targetId = await getImpersonationId(request);
if (targetId) {
if (targetId && authUser?.userId) {
const xff = request.headers.get("x-forwarded-for");
const ipAddress = extractClientIp(xff);
@@ -253,13 +255,17 @@ export async function clearImpersonation(request: Request, path: string) {
await prisma.impersonationAuditLog.create({
data: {
action: "STOP",
adminId: user.id,
adminId: authUser.userId,
targetId,
ipAddress,
},
});
} catch (error) {
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId });
logger.error("Failed to create impersonation audit log", {
error,
adminId: authUser.userId,
targetId,
});
}
}
@@ -1,6 +1,7 @@
import type {
Organization,
OrgMember,
Prisma,
Project,
RuntimeEnvironment,
User,
@@ -22,8 +23,12 @@ export async function createOrganization(
title,
userId,
companySize,
onboardingData,
avatar,
}: Pick<Organization, "title" | "companySize"> & {
userId: User["id"];
onboardingData?: Prisma.InputJsonValue;
avatar?: Prisma.InputJsonValue;
},
attemptCount = 0
): Promise<Organization> {
@@ -47,6 +52,8 @@ export async function createOrganization(
title,
userId,
companySize,
onboardingData,
avatar,
},
attemptCount + 1
);
@@ -59,6 +66,8 @@ export async function createOrganization(
title,
slug: uniqueOrgSlug,
companySize,
onboardingData: onboardingData ?? undefined,
avatar: avatar ?? undefined,
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
members: {
create: {
+6 -3
View File
@@ -1,8 +1,8 @@
import { nanoid, customAlphabet } from "nanoid";
import slug from "slug";
import { $replica, prisma } from "~/db.server";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
import type { Prisma, Project } from "@trigger.dev/database";
import { type Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
import { projectCreated } from "~/services/platform.v3.server";
export type { Project } from "@trigger.dev/database";
@@ -14,6 +14,7 @@ type Options = {
name: string;
userId: string;
version: "v2" | "v3";
onboardingData?: Prisma.InputJsonValue;
};
export class ExceededProjectLimitError extends Error {
@@ -24,7 +25,7 @@ export class ExceededProjectLimitError extends Error {
}
export async function createProject(
{ organizationSlug, name, userId, version }: Options,
{ organizationSlug, name, userId, version, onboardingData }: Options,
attemptCount = 0
): Promise<Project & { organization: Organization }> {
//check the user has permissions to do this
@@ -84,6 +85,7 @@ export async function createProject(
name,
userId,
version,
onboardingData,
},
attemptCount + 1
);
@@ -100,6 +102,7 @@ export async function createProject(
},
externalRef: `proj_${externalRefGenerator()}`,
version: version === "v3" ? "V3" : "V2",
onboardingData,
},
include: {
organization: {
+3 -1
View File
@@ -332,13 +332,15 @@ export function updateUser({
email,
marketingEmails,
referralSource,
onboardingData,
}: Pick<User, "id" | "name" | "email"> & {
marketingEmails?: boolean;
referralSource?: string;
onboardingData?: Prisma.InputJsonValue;
}) {
return prisma.user.update({
where: { id },
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true },
});
}
@@ -27,6 +27,11 @@ import {
envTypeToVercelTarget,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
callVercelWithRecovery,
wrapVercelCallWithRecovery,
VercelSchemas,
} from "./vercelSdkRecovery.server";
// ---------------------------------------------------------------------------
// Pure helpers
@@ -314,17 +319,21 @@ export class VercelIntegrationRepository {
teamId: string | null
): ResultAsync<string, VercelApiError> {
if (teamId) {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.teams.getTeam({ teamId }),
VercelSchemas.getTeam,
"Failed to fetch Vercel team",
{ teamId }
{ teamId },
toVercelApiError
).map((response) => response.slug);
}
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.user.getAuthUser(),
VercelSchemas.getAuthUser,
"Failed to fetch Vercel user",
{}
{},
toVercelApiError
).map((response) => response?.user.username ?? "unknown");
}
@@ -333,10 +342,11 @@ export class VercelIntegrationRepository {
): ResultAsync<{ isValid: boolean }, VercelApiError> {
return this.getVercelClient(integration)
.andThen((client) =>
ResultAsync.fromPromise(
callVercelWithRecovery(
client.user.getAuthUser(),
toVercelApiError
)
VercelSchemas.getAuthUser,
{ context: "validateVercelToken" }
).mapErr(toVercelApiError)
)
.map(() => ({ isValid: true }))
.orElse((error) =>
@@ -420,13 +430,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null
): ResultAsync<VercelCustomEnvironment[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.getCustomEnvironments,
"Failed to fetch Vercel custom environments",
{ projectId, teamId }
{ projectId, teamId },
toVercelApiError
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
}
@@ -435,13 +447,15 @@ export class VercelIntegrationRepository {
projectId: string,
teamId?: string | null,
): ResultAsync<VercelEnvironmentVariable[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variables",
{ projectId, teamId }
{ projectId, teamId },
toVercelApiError
).map((response) => {
// Warn if response is paginated (more data exists that we're not fetching)
if (
@@ -467,13 +481,15 @@ export class VercelIntegrationRepository {
/** If provided, only include keys that pass this filter */
shouldIncludeKey?: (key: string) => boolean
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.filterProjectEnvs({
idOrName: projectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
"Failed to fetch Vercel environment variable values",
{ projectId, teamId, target }
{ projectId, teamId, target },
toVercelApiError
).andThen((response) => {
// Apply all filters BEFORE decryption to avoid unnecessary API calls
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
@@ -510,13 +526,14 @@ export class VercelIntegrationRepository {
// Encrypted vars: fetch decrypted value via individual endpoint
// (list endpoint's decrypt param is deprecated)
const result = await ResultAsync.fromPromise(
const result = await callVercelWithRecovery(
client.projects.getProjectEnv({
idOrName: projectId,
id: env.id,
...(teamId && { teamId }),
}),
(error) => error
VercelSchemas.getProjectEnv,
{ context: "resolveEnvVarValue" }
);
if (result.isErr()) {
@@ -552,13 +569,15 @@ export class VercelIntegrationRepository {
isSecret: boolean;
target: string[];
}>, VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variables",
{ teamId, projectId }
{ teamId, projectId },
toVercelApiError
).map((response) => {
const envVars = response.data || [];
return envVars
@@ -593,13 +612,15 @@ export class VercelIntegrationRepository {
}>,
VercelApiError
> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variable values",
{ teamId, projectId }
{ teamId, projectId },
toVercelApiError
).andThen((listResponse) => {
const envVars = listResponse.data || [];
if (envVars.length === 0) {
@@ -635,12 +656,13 @@ export class VercelIntegrationRepository {
}
// Try to get the decrypted value for this shared env var
const getResult = await ResultAsync.fromPromise(
const getResult = await callVercelWithRecovery(
client.environment.getSharedEnvVar({
id: envId,
teamId,
}),
(error) => error
VercelSchemas.getSharedEnvVar,
{ context: "getSharedEnvVar" }
);
if (getResult.isOk()) {
@@ -655,47 +677,12 @@ export class VercelIntegrationRepository {
};
}
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
const error = getResult.error;
let errorValue: string | undefined;
if (error && typeof error === "object" && "rawValue" in error) {
const rawValue = (error as any).rawValue;
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
errorValue = rawValue.value as string | undefined;
}
}
const fallbackValue = errorValue || listValue;
if (fallbackValue) {
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
teamId,
envId,
envKey,
error: error instanceof Error ? error.message : String(error),
hasErrorRawValue: !!errorValue,
hasListValue: !!listValue,
valueLength: fallbackValue.length,
});
return {
key: envKey,
value: fallbackValue,
target: normalizeTarget(env.target),
type,
isSecret,
applyToAllCustomEnvironments: applyToAllCustomEnvs,
};
}
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
logger.warn("Failed to get decrypted value for shared env var", {
teamId,
projectId,
envId,
envKey,
error: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
hasRawValue: error && typeof error === "object" && "rawValue" in error,
error: getResult.error instanceof Error ? getResult.error.message : String(getResult.error),
});
return null;
})
@@ -723,11 +710,18 @@ export class VercelIntegrationRepository {
let from: string | undefined;
do {
const response = await client.projects.getProjects({
...(teamId && { teamId }),
limit: "100",
...(from && { from }),
});
const response = await callVercelWithRecovery(
client.projects.getProjects({
...(teamId && { teamId }),
limit: "100",
...(from && { from }),
}),
VercelSchemas.getProjects,
{ context: "getVercelProjects" }
).match(
(val) => val,
(err) => { throw err; }
);
const projects = Array.isArray(response)
? response
@@ -975,6 +969,13 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] as string[] };
}
await this.removeAllVercelEnvVarsByKey({
client,
vercelProjectId: params.vercelProjectId,
teamId: params.teamId,
key: "TRIGGER_SECRET_KEY",
});
const result = await this.batchUpsertVercelEnvVars({
client,
vercelProjectId: params.vercelProjectId,
@@ -1081,6 +1082,111 @@ export class VercelIntegrationRepository {
);
}
static upsertEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
value: string;
customEnvironmentId: string;
type: "sensitive" | "encrypted" | "plain";
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
if (existingEnv && existingEnv.id) {
await client.projects.editProjectEnv({
idOrName: vercelProjectId,
id: existingEnv.id,
...(teamId && { teamId }),
requestBody: {
value,
type,
},
});
} else {
await client.projects.createProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {
key,
value,
type,
customEnvironmentIds: [customEnvironmentId],
} as any,
});
}
})(),
(error) => toVercelApiError(error)
)
);
}
static removeEnvVarForCustomEnvironment(params: {
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
vercelProjectId: string;
teamId: string | null;
key: string;
customEnvironmentId: string;
}): ResultAsync<void, VercelApiError> {
return this.getVercelClient(params.orgIntegration).andThen((client) =>
ResultAsync.fromPromise(
(async () => {
const { vercelProjectId, teamId, key, customEnvironmentId } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeEnvVarForCustomEnvironment" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const existingEnv = envs.find((env) => {
if (env.key !== key) return false;
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
});
if (existingEnv && existingEnv.id) {
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: [existingEnv.id] },
});
}
})(),
(error) => toVercelApiError(error)
)
);
}
static pullEnvVarsFromVercel(params: {
projectId: string;
vercelProjectId: string;
@@ -1188,7 +1294,7 @@ export class VercelIntegrationRepository {
);
if (envVarsResult.isErr()) {
logger.error("pullEnvVarsFromVercel: Failed to get env vars", {
logger.warn("pullEnvVarsFromVercel: Failed to get env vars", {
triggerEnvType: mapping.triggerEnvType,
vercelTarget: mapping.vercelTarget,
error: envVarsResult.error.message,
@@ -1409,10 +1515,17 @@ export class VercelIntegrationRepository {
return { created: 0, updated: 0, errors: [] };
}
const existingEnvs = await client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
});
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "batchUpsertVercelEnvVars" }
).match(
(val) => val,
(err) => { throw err; }
);
const existingEnvsList = extractVercelEnvs(existingEnvs);
@@ -1526,6 +1639,42 @@ export class VercelIntegrationRepository {
return { created, updated, errors };
}
private static async removeAllVercelEnvVarsByKey(params: {
client: Vercel;
vercelProjectId: string;
teamId: string | null;
key: string;
}): Promise<void> {
const { client, vercelProjectId, teamId, key } = params;
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "removeAllVercelEnvVarsByKey" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
const idsToRemove = envs
.filter((env) => env.key === key && env.id)
.map((env) => env.id!);
if (idsToRemove.length === 0) {
return;
}
await client.projects.batchRemoveProjectEnv({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: { ids: idsToRemove },
});
}
private static async upsertVercelEnvVar(params: {
client: Vercel;
vercelProjectId: string;
@@ -1537,10 +1686,17 @@ export class VercelIntegrationRepository {
}): Promise<void> {
const { client, vercelProjectId, teamId, key, value, target, type } = params;
const existingEnvs = await client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
});
const existingEnvs = await callVercelWithRecovery(
client.projects.filterProjectEnvs({
idOrName: vercelProjectId,
...(teamId && { teamId }),
}),
VercelSchemas.filterProjectEnvs,
{ context: "upsertVercelEnvVar" }
).match(
(val) => val,
(err) => { throw err; }
);
const envs = extractVercelEnvs(existingEnvs);
@@ -1584,14 +1740,16 @@ export class VercelIntegrationRepository {
teamId?: string | null
): ResultAsync<boolean | null, VercelApiError> {
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
requestBody: {},
}),
VercelSchemas.updateProject,
"Failed to get Vercel project autoAssignCustomDomains",
{ vercelProjectId, teamId }
{ vercelProjectId, teamId },
toVercelApiError
).map((project) => project.autoAssignCustomDomains ?? null);
}
@@ -1601,7 +1759,7 @@ export class VercelIntegrationRepository {
vercelProjectId: string,
teamId?: string | null
): ResultAsync<void, VercelApiError> {
return wrapVercelCall(
return wrapVercelCallWithRecovery(
client.projects.updateProject({
idOrName: vercelProjectId,
...(teamId && { teamId }),
@@ -1609,8 +1767,10 @@ export class VercelIntegrationRepository {
autoAssignCustomDomains: false,
},
}),
VercelSchemas.updateProject,
"Failed to disable autoAssignCustomDomains",
{ vercelProjectId, teamId }
{ vercelProjectId, teamId },
toVercelApiError
).map(() => undefined);
}
@@ -0,0 +1,195 @@
import { z } from "zod";
import { ResultAsync, okAsync, errAsync } from "neverthrow";
import { logger } from "~/services/logger.server";
import type { VercelApiError } from "./vercelIntegration.server";
// ---------------------------------------------------------------------------
// Recovery utilities for Vercel SDK validation errors
// ---------------------------------------------------------------------------
//
// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
// schemas. When the API returns valid data but a field doesn't match the SDK's
// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
// thrown — even though the response contains all the data we need.
//
// Error hierarchy:
// VercelError.body → raw HTTP body text (HTTP errors — never recover)
// ResponseValidationError.rawValue → parsed JSON that failed validation
// SDKValidationError.rawValue → same pattern, different base class
//
// Recovery: gate on validation error type → extract rawValue → validate → return.
// ---------------------------------------------------------------------------
/**
* Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
*
* ResponseValidationError and SDKValidationError both carry `rawValue` with the
* parsed JSON that failed schema validation. VercelError (HTTP errors) carries
* `body` instead — we must NOT recover from those since the response is an error
* payload, not the data we asked for.
*/
function isValidationError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
if (!(error instanceof Error)) return false;
return (
error.constructor.name === "ResponseValidationError" ||
error.constructor.name === "SDKValidationError" ||
"rawValue" in error
);
}
function extractRawValue(error: unknown): unknown | undefined {
if (!error || typeof error !== "object") return undefined;
if ("rawValue" in error) {
return (error as { rawValue: unknown }).rawValue;
}
return undefined;
}
/**
* Attempt to recover usable data from a Vercel SDK error.
*
* Returns the validated data on success, or `undefined` if recovery fails.
*/
export function recoverFromVercelSdkError<T>(
error: unknown,
schema: z.ZodType<any>,
options?: { context?: string }
): T | undefined {
if (!isValidationError(error)) return undefined;
const raw = extractRawValue(error);
if (raw === undefined) return undefined;
const result = schema.safeParse(raw);
if (!result.success) return undefined;
logger.warn("Recovered data from Vercel SDK validation error", {
context: options?.context,
errorMessage: error instanceof Error ? error.message : String(error),
errorType: error?.constructor?.name,
});
return result.data;
}
/**
* Wrap a Vercel SDK promise with automatic recovery on validation errors.
*
* On success: returns the SDK result as-is.
* On error: attempts recovery via rawValue + schema validation (validation errors only).
*/
export function callVercelWithRecovery<T>(
sdkCall: Promise<T>,
schema: z.ZodType<any>,
options?: { context?: string }
): ResultAsync<T, unknown> {
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
if (recovered !== undefined) {
return okAsync(recovered);
}
return errAsync(error);
});
}
/**
* Drop-in replacement for `wrapVercelCall` with SDK error recovery.
*
* Wraps a Vercel SDK promise in ResultAsync with structured error logging,
* attempting to recover from validation errors before treating as failure.
*/
export function wrapVercelCallWithRecovery<T>(
promise: Promise<T>,
schema: z.ZodType<any>,
message: string,
context: Record<string, unknown>,
toError: (error: unknown) => VercelApiError
): ResultAsync<T, VercelApiError> {
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
const apiError = toError(error);
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
return apiError;
});
}
// ---------------------------------------------------------------------------
// Minimal Zod schemas — validate only the fields we actually use.
// All use .passthrough() to preserve extra fields from the API response.
// ---------------------------------------------------------------------------
export const VercelSchemas = {
getTeam: z.object({ slug: z.string() }).passthrough(),
getAuthUser: z
.object({ user: z.object({ username: z.string() }).passthrough() })
.passthrough(),
getCustomEnvironments: z
.object({
environments: z
.array(
z
.object({
id: z.string(),
slug: z.string(),
description: z.string().optional(),
branchMatcher: z.unknown().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough(),
filterProjectEnvs: z
.union([
z
.object({
envs: z.array(z.record(z.unknown())),
pagination: z.unknown().optional(),
})
.passthrough(),
z.array(z.record(z.unknown())),
])
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
getProjects: z.union([
z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
z
.object({
projects: z.array(
z.object({ id: z.string(), name: z.string() }).passthrough()
),
pagination: z.unknown().optional(),
})
.passthrough(),
]),
listSharedEnvVariable: z
.object({
data: z
.array(
z
.object({
id: z.string().optional(),
key: z.string().optional(),
type: z.string().optional(),
target: z.unknown().optional(),
value: z.string().optional(),
})
.passthrough()
)
.optional(),
})
.passthrough(),
getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
updateProject: z
.object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
.passthrough(),
} as const;

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