Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Add `maxDelay` option to debounce feature. This allows setting a maximum time limit for how long a debounced run can be delayed, ensuring execution happens within a specified window even with continuous triggers.
|
||||
|
||||
```typescript
|
||||
await myTask.trigger(payload, {
|
||||
debounce: {
|
||||
key: "my-key",
|
||||
delay: "5s",
|
||||
maxDelay: "30m", // Execute within 30 minutes regardless of continuous triggers
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
fix: vendor superjson to fix ESM/CJS compatibility
|
||||
|
||||
Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it.
|
||||
@@ -29,3 +29,7 @@ jobs:
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
sdk-compat:
|
||||
uses: ./.github/workflows/sdk-compat.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
name: "🔌 SDK Compatibility Tests"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
node-compat:
|
||||
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: ["20.20", "22.12"]
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk^...'
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk'
|
||||
|
||||
- name: 🧪 Run SDK Compatibility Tests
|
||||
shell: bash
|
||||
run: pnpm --filter @internal/sdk-compat-tests test
|
||||
|
||||
bun-compat:
|
||||
name: "Bun Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🥟 Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🧪 Run Bun Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
|
||||
run: bun run test.ts
|
||||
|
||||
deno-compat:
|
||||
name: "Deno Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🦕 Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🔗 Link node_modules for Deno fixture
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: ln -s ../../../../../node_modules node_modules
|
||||
|
||||
- name: 🧪 Run Deno Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: deno run --allow-read --allow-env --allow-sys test.ts
|
||||
|
||||
cloudflare-compat:
|
||||
name: "Cloudflare Workers"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 📥 Install Cloudflare fixture deps
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: pnpm install
|
||||
|
||||
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: npx wrangler deploy --dry-run --outdir dist
|
||||
@@ -15,6 +15,9 @@ out/
|
||||
dist
|
||||
packages/**/dist
|
||||
|
||||
# vendored bundles (generated during build)
|
||||
packages/**/src/**/vendor
|
||||
|
||||
# Tailwind
|
||||
apps/**/styles/tailwind.css
|
||||
packages/**/styles/tailwind.css
|
||||
|
||||
@@ -112,6 +112,11 @@ const Env = z.object({
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
|
||||
|
||||
// Project affinity settings - pods from the same project prefer the same node
|
||||
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
|
||||
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
|
||||
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),
|
||||
|
||||
@@ -120,7 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
spec: {
|
||||
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
|
||||
affinity: this.#getNodeAffinity(opts.machine),
|
||||
affinity: this.#getAffinity(opts.machine, opts.projectId),
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
@@ -390,7 +390,21 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
return preset.name.startsWith("large-");
|
||||
}
|
||||
|
||||
#getNodeAffinity(preset: MachinePreset): k8s.V1Affinity | undefined {
|
||||
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
|
||||
const nodeAffinity = this.#getNodeAffinityRules(preset);
|
||||
const podAffinity = this.#getProjectPodAffinity(projectId);
|
||||
|
||||
if (!nodeAffinity && !podAffinity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(nodeAffinity && { nodeAffinity }),
|
||||
...(podAffinity && { podAffinity }),
|
||||
};
|
||||
}
|
||||
|
||||
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined {
|
||||
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -398,42 +412,64 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
if (this.#isLargeMachine(preset)) {
|
||||
// soft preference for the large-machine pool, falls back to standard if unavailable
|
||||
return {
|
||||
nodeAffinity: {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// not schedulable in the large-machine pool
|
||||
return {
|
||||
nodeAffinity: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
|
||||
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
|
||||
podAffinityTerm: {
|
||||
labelSelector: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "project",
|
||||
operator: "In",
|
||||
values: [projectId],
|
||||
},
|
||||
],
|
||||
},
|
||||
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
|
||||
export function LogLevelTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<Header3>Log Levels</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Structured logging helps you debug and monitor your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-blue-400">Info</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
General informational messages about task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-warning">Warn</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Warning messages indicating potential issues that don't prevent execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-error">Error</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Error messages for failures and exceptions during task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-charcoal-400">Debug</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Detailed diagnostic information for development and debugging.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 pt-4">
|
||||
<Header3>Tracing & Spans</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Automatically track the flow of your code through task triggers, attempts, and HTTP
|
||||
requests. Create custom traces to monitor specific operations.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
to="https://trigger.dev/docs/logging#tracing-and-spans"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,6 +161,37 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Logs page</Header3>
|
||||
<Shortcut name="Filter by task">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by run ID">
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by level">
|
||||
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select log level">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Close detail panel">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details tab">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Run tab">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="View full run">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
|
||||
// Color palette for chart series
|
||||
// Color palette for chart series - 30 distinct colors for large datasets
|
||||
const CHART_COLORS = [
|
||||
"#7655fd", // Primary purple
|
||||
// Primary colors
|
||||
"#7655fd", // Purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
@@ -17,6 +18,28 @@ const CHART_COLORS = [
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
// Extended palette
|
||||
"#84cc16", // Lime
|
||||
"#0ea5e9", // Sky
|
||||
"#f43f5e", // Rose
|
||||
"#a855f7", // Fuchsia
|
||||
"#eab308", // Yellow
|
||||
"#10b981", // Emerald
|
||||
"#3b82f6", // Blue
|
||||
"#d946ef", // Magenta
|
||||
"#78716c", // Stone
|
||||
"#facc15", // Gold
|
||||
// Additional distinct colors
|
||||
"#2dd4bf", // Turquoise
|
||||
"#fb923c", // Light orange
|
||||
"#a3e635", // Yellow-green
|
||||
"#38bdf8", // Light blue
|
||||
"#c084fc", // Light purple
|
||||
"#4ade80", // Light green
|
||||
"#fbbf24", // Light amber
|
||||
"#f472b6", // Light pink
|
||||
"#67e8f9", // Light cyan
|
||||
"#818cf8", // Light indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
@@ -30,6 +53,8 @@ interface QueryResultsChartProps {
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
@@ -702,6 +727,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
@@ -872,6 +898,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
@@ -896,6 +923,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
|
||||
@@ -672,7 +672,9 @@ function EnvironmentCellValue({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
function JSONCellValue({ value }: { value: unknown }) {
|
||||
const jsonString = JSON.stringify(value);
|
||||
// If the value is already a string (e.g., from a textColumn optimization),
|
||||
// use it directly without double-stringifying
|
||||
const jsonString = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
@@ -1137,6 +1139,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
className="bg-background-dimmed divide-y divide-charcoal-700"
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = tableRows[virtualRow.index];
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
|
||||
@@ -94,16 +94,34 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const log = fetcher.data ?? initialLog;
|
||||
|
||||
// Handle Escape key to close panel
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log?.runId ?? "" },
|
||||
{ spanId: log?.spanId ?? "" }
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.contentEditable === "true"
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
}, [onClose, log, runPath, isLoading]);
|
||||
|
||||
if (isLoading && !log) {
|
||||
return (
|
||||
@@ -129,36 +147,18 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
);
|
||||
}
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log.runId },
|
||||
{ spanId: log.spanId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium",
|
||||
getKindColor(log.kind)
|
||||
)}
|
||||
>
|
||||
{getKindLabel(log.kind)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
@@ -185,8 +185,8 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View Full Run
|
||||
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
|
||||
View full run
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { IconListTree } from "@tabler/icons-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
@@ -12,24 +11,20 @@ import {
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
|
||||
{ level: "TRACE", label: "Trace", color: "text-charcoal-500" },
|
||||
];
|
||||
|
||||
function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
|
||||
if (showDebug) {
|
||||
return allLogLevels;
|
||||
}
|
||||
return allLogLevels.filter((level) => level.level !== "DEBUG");
|
||||
// In the future we might add other levels or change which are available
|
||||
function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
@@ -42,10 +37,6 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
@@ -53,81 +44,50 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
export function LogsLevelFilter() {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter showDebug={showDebug} />;
|
||||
return <AppliedLevelFilter/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<IconListTree className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
showDebug = false,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
showDebug?: boolean;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels(showDebug);
|
||||
const filtered = useMemo(() => {
|
||||
return availableLevels.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue, availableLevels]);
|
||||
const availableLevels = getAvailableLevels();
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by level..." value={searchValue} />
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
{availableLevels.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.level}
|
||||
value={item.level}
|
||||
@@ -149,7 +109,7 @@ function LevelDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
function AppliedLevelFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
@@ -158,25 +118,18 @@ function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<IconListTree className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "r" };
|
||||
const shortcut = { key: "i" };
|
||||
|
||||
export function LogsRunIdFilter() {
|
||||
const { value } = useSearchParams();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
@@ -52,7 +53,16 @@ export function LogsSearchInput() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative h-6 min-w-52">
|
||||
<motion.div
|
||||
initial={{ width: "auto" }}
|
||||
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="relative h-6 min-w-52"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
@@ -80,7 +90,7 @@ export function LogsSearchInput() {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{text.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
|
||||
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
@@ -37,25 +39,23 @@ type LogsTableProps = {
|
||||
onLogSelect?: (logId: string) => void;
|
||||
};
|
||||
|
||||
// Left border color for error highlighting
|
||||
function getLevelBorderColor(level: LogEntry["level"]): string {
|
||||
// Inner shadow for level highlighting (better scroll performance than border-l)
|
||||
function getLevelBoxShadow(level: LogEntry["level"]): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "border-l-error";
|
||||
return "inset 2px 0 0 0 rgb(239, 68, 68)";
|
||||
case "WARN":
|
||||
return "border-l-warning";
|
||||
return "inset 2px 0 0 0 rgb(234, 179, 8)";
|
||||
case "INFO":
|
||||
return "border-l-blue-500";
|
||||
case "CANCELLED":
|
||||
return "border-l-charcoal-600";
|
||||
return "inset 2px 0 0 0 rgb(59, 130, 246)";
|
||||
case "DEBUG":
|
||||
case "TRACE":
|
||||
default:
|
||||
return "border-l-transparent hover:border-l-charcoal-800";
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function LogsTable({
|
||||
logs,
|
||||
searchTerm,
|
||||
@@ -112,14 +112,19 @@ export function LogsTable({
|
||||
}, [hasMore, isLoadingMore, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible">
|
||||
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Level</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
className="min-w-24 whitespace-nowrap"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
>
|
||||
Level
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -143,8 +148,7 @@ export function LogsTable({
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-2 transition-colors",
|
||||
getLevelBorderColor(log.level),
|
||||
"cursor-pointer transition-colors",
|
||||
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
|
||||
)}
|
||||
isSelected={isSelected}
|
||||
@@ -153,6 +157,9 @@ export function LogsTable({
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
onClick={handleRowClick}
|
||||
hasAction
|
||||
style={{
|
||||
boxShadow: getLevelBoxShadow(log.level),
|
||||
}}
|
||||
>
|
||||
<DateTime date={log.startTime} />
|
||||
</TableCell>
|
||||
@@ -180,12 +187,11 @@ export function LogsTable({
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
hiddenButtons={
|
||||
<PopoverMenuItem
|
||||
openInNewTab={true}
|
||||
to={runPath}
|
||||
icon={ArrowTopRightOnSquareIcon}
|
||||
title="View Run"
|
||||
/>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View run
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
@@ -196,12 +202,23 @@ export function LogsTable({
|
||||
</Table>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && logs.length > 0 && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{showLoadMoreSpinner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
!showLoadMoreSpinner && "invisible"
|
||||
)}
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show all logs message */}
|
||||
{!hasMore && logs.length > 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
|
||||
const shortcut = { key: "t" };
|
||||
|
||||
type TaskOption = {
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
};
|
||||
|
||||
interface LogsTaskFilterProps {
|
||||
possibleTasks: TaskOption[];
|
||||
}
|
||||
|
||||
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedTasks = values("tasks");
|
||||
|
||||
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by task"
|
||||
>
|
||||
Tasks
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
value={appliedSummary(
|
||||
selectedTasks.map((v) => {
|
||||
const task = possibleTasks.find((task) => task.slug === v);
|
||||
return task ? task.slug : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["tasks", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleTasks: TaskOption[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ tasks: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleTasks.filter((item) => {
|
||||
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleTasks]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={`${item.triggerSource}-${item.slug}`}
|
||||
value={item.slug}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,64 @@
|
||||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) {
|
||||
/**
|
||||
* Determines the number of decimal places to display based on the value.
|
||||
* - For integers or large numbers (>=100), no decimals
|
||||
* - For numbers >= 10, 1 decimal place
|
||||
* - For numbers >= 1, 2 decimal places
|
||||
* - For smaller numbers, up to 4 decimal places
|
||||
*/
|
||||
function getDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) return 0;
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100) return 0;
|
||||
if (absValue >= 10) return 1;
|
||||
if (absValue >= 1) return 2;
|
||||
if (absValue >= 0.1) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
|
||||
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
|
||||
* - Rounds to an integer
|
||||
* - Clamps to the valid 0-20 range for toLocaleString options
|
||||
*/
|
||||
function sanitizeDecimals(decimals: number): number {
|
||||
if (!Number.isFinite(decimals)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(20, Math.max(0, Math.round(decimals)));
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 0.5,
|
||||
decimalPlaces,
|
||||
}: {
|
||||
value: number;
|
||||
duration?: number;
|
||||
/** Number of decimal places to display. If not provided, auto-detects based on value. */
|
||||
decimalPlaces?: number;
|
||||
}) {
|
||||
const motionValue = useMotionValue(value);
|
||||
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
|
||||
|
||||
// Determine decimal places - use provided value or auto-detect, then sanitize
|
||||
const safeDecimals = useMemo(() => {
|
||||
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
|
||||
return sanitizeDecimals(rawDecimals);
|
||||
}, [decimalPlaces, value]);
|
||||
|
||||
const display = useTransform(motionValue, (current) => {
|
||||
if (safeDecimals === 0) {
|
||||
return Math.round(current).toLocaleString();
|
||||
}
|
||||
return current.toLocaleString(undefined, {
|
||||
minimumFractionDigits: safeDecimals,
|
||||
maximumFractionDigits: safeDecimals,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
animate(motionValue, value, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -60,10 +61,10 @@ export const variantClasses = {
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
className: "border-indigo-400/20 bg-indigo-800/30",
|
||||
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-indigo-400" />,
|
||||
textColor: "text-indigo-300",
|
||||
linkClassName: "transition hover:bg-indigo-400/20",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useRef, useState, useLayoutEffect, useCallback } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
type MiddleTruncateProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that truncates text in the middle, showing the beginning and end.
|
||||
* Shows the full text in a tooltip on hover when truncated.
|
||||
*
|
||||
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
|
||||
*/
|
||||
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const calculateTruncation = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const parent = container.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Get the available width from the parent container
|
||||
const parentStyle = getComputedStyle(parent);
|
||||
const availableWidth =
|
||||
parent.clientWidth -
|
||||
parseFloat(parentStyle.paddingLeft) -
|
||||
parseFloat(parentStyle.paddingRight);
|
||||
|
||||
// Measure full text width
|
||||
measure.textContent = text;
|
||||
const fullTextWidth = measure.offsetWidth;
|
||||
|
||||
// If text fits, no truncation needed
|
||||
if (fullTextWidth <= availableWidth) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text needs truncation - find optimal split
|
||||
const ellipsis = "…";
|
||||
measure.textContent = ellipsis;
|
||||
const ellipsisWidth = measure.offsetWidth;
|
||||
|
||||
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
setDisplayText(ellipsis);
|
||||
setIsTruncated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incrementally find the optimal character counts
|
||||
let startChars = 0;
|
||||
let endChars = 0;
|
||||
|
||||
// Alternate adding characters from start and end
|
||||
while (startChars + endChars < text.length) {
|
||||
// Try adding to start
|
||||
const testStart = text.slice(0, startChars + 1);
|
||||
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
|
||||
measure.textContent = testStart + ellipsis + testEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
startChars++;
|
||||
|
||||
if (startChars + endChars >= text.length) break;
|
||||
|
||||
// Try adding to end
|
||||
const newTestEnd = text.slice(-(endChars + 1));
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
endChars++;
|
||||
}
|
||||
|
||||
// Ensure minimum characters on each side for readability
|
||||
const minChars = 4;
|
||||
const prevStartChars = startChars;
|
||||
const prevEndChars = endChars;
|
||||
|
||||
if (startChars < minChars && text.length > minChars * 2 + 1) {
|
||||
startChars = minChars;
|
||||
}
|
||||
if (endChars < minChars && text.length > minChars * 2 + 1) {
|
||||
endChars = minChars;
|
||||
}
|
||||
|
||||
// Re-measure after enforcing minChars to prevent overflow
|
||||
if (startChars !== prevStartChars || endChars !== prevEndChars) {
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
if (measure.offsetWidth > targetWidth) {
|
||||
// Revert to previous values if minChars enforcement causes overflow
|
||||
startChars = prevStartChars;
|
||||
endChars = prevEndChars;
|
||||
}
|
||||
}
|
||||
|
||||
// If combined chars would exceed text length, show full text
|
||||
if (startChars + endChars >= text.length) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
setDisplayText(result);
|
||||
setIsTruncated(true);
|
||||
}, [text]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
calculateTruncation();
|
||||
|
||||
// Recalculate on resize (guard for jsdom/older browsers)
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateTruncation();
|
||||
});
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container?.parentElement) {
|
||||
resizeObserver.observe(container.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateTruncation]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={cn("block", isTruncated && "min-w-[360px]", className)}
|
||||
>
|
||||
{/* Hidden span for measuring text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="invisible absolute whitespace-nowrap"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={content}
|
||||
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
|
||||
side="top"
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -26,19 +26,40 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<PanelResizer
|
||||
className={cn(
|
||||
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
// Base styles
|
||||
"group relative flex items-center justify-center focus-custom",
|
||||
// Horizontal orientation (default)
|
||||
"w-0.75 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2",
|
||||
// Vertical orientation
|
||||
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
|
||||
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
|
||||
"data-[handle-orientation=vertical]:after:top-1/2 data-[handle-orientation=vertical]:after:left-0",
|
||||
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
|
||||
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
|
||||
className
|
||||
)}
|
||||
size="3px"
|
||||
{...props}
|
||||
>
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
|
||||
{/* Horizontal orientation line indicator */}
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
{/* Vertical orientation line indicator */}
|
||||
<div className="absolute left-0 top-[0.0625rem] hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:block" />
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-5 w-3 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Horizontal orientation dots (vertical arrangement) */}
|
||||
<div className="z-10 flex h-5 w-0.75 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
{/* Vertical orientation dots (horizontal arrangement) */}
|
||||
<div className="z-10 hidden h-0.75 w-5 flex-row items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:flex">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelResizer>
|
||||
);
|
||||
|
||||
@@ -64,18 +64,30 @@ type TableProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
};
|
||||
|
||||
// Add TableContext
|
||||
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
|
||||
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
fullWidth,
|
||||
variant = "dimmed",
|
||||
showTopBorder = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<TableContext.Provider value={{ variant }}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
"overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
showTopBorder && "border-t",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -230,6 +242,7 @@ type TableCellProps = TableCellBasicProps & {
|
||||
isSelected?: boolean;
|
||||
isTabbableCell?: boolean;
|
||||
children?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
@@ -246,6 +259,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
isSticky = false,
|
||||
isSelected,
|
||||
isTabbableCell = false,
|
||||
style,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -291,6 +305,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
style={style}
|
||||
>
|
||||
{to ? (
|
||||
<Link
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-2 pt-4",
|
||||
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -17,7 +17,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
|
||||
const CardHeader = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Header3 className="mb-4 flex items-center justify-between gap-2 px-4">{children}</Header3>
|
||||
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export type ChartLegendCompoundProps = {
|
||||
totalLabel?: string;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
scrollable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,7 @@ export function ChartLegendCompound({
|
||||
className,
|
||||
totalLabel = "Total",
|
||||
onViewAllLegendItems,
|
||||
scrollable = false,
|
||||
}: ChartLegendCompoundProps) {
|
||||
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
|
||||
const totals = useSeriesTotal();
|
||||
@@ -128,11 +131,17 @@ export function ChartLegendCompound({
|
||||
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col pt-4 text-sm", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col pt-4 text-sm",
|
||||
scrollable && "max-h-[50%] min-h-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Total row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
"flex w-full shrink-0 items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
isHovering ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
@@ -143,62 +152,68 @@ export function ChartLegendCompound({
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-2 my-1 border-t border-charcoal-750" />
|
||||
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
|
||||
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
{/* Legend items - scrollable when scrollable prop is true */}
|
||||
<div className={cn("flex flex-col", scrollable && "min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600")}>
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
>
|
||||
{/* Active highlight background */}
|
||||
{isActive && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
>
|
||||
{/* Active highlight background */}
|
||||
{isActive && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-1 shrink-0 self-stretch rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"self-start tabular-nums",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn("tabular-nums", isActive ? "text-text-bright" : "text-text-dimmed")}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* View more row - replaced by hovered hidden item when applicable */}
|
||||
{legendItems.remaining > 0 &&
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
))}
|
||||
{/* View more row - replaced by hovered hidden item when applicable */}
|
||||
{legendItems.remaining > 0 &&
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ChartLineRenderer({
|
||||
stroke={config[key]?.color}
|
||||
fill={config[key]?.color}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2}
|
||||
strokeWidth={1}
|
||||
stackId="stack"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
@@ -220,7 +220,7 @@ export function ChartLineRenderer({
|
||||
dataKey={key}
|
||||
type={lineType}
|
||||
stroke={config[key]?.color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type ChartRootProps = {
|
||||
legendTotalLabel?: string;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
/** When true, chart fills its parent container height and distributes space between chart and legend */
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
@@ -72,6 +74,7 @@ export function ChartRoot({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootProps) {
|
||||
@@ -94,6 +97,7 @@ export function ChartRoot({
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
fillContainer={fillContainer}
|
||||
>
|
||||
{children}
|
||||
@@ -109,6 +113,7 @@ type ChartRootInnerProps = {
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
onViewAllLegendItems?: () => void;
|
||||
legendScrollable?: boolean;
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
@@ -120,6 +125,7 @@ function ChartRootInner({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootInnerProps) {
|
||||
@@ -160,6 +166,7 @@ function ChartRootInner({
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
scrollable={legendScrollable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
@@ -634,7 +635,7 @@ function TasksDropdown({
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -654,7 +655,7 @@ function TasksDropdown({
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
<MiddleTruncate text={item.slug}/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
|
||||
@@ -4,36 +4,31 @@ import {
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
previousSaturday,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subWeeks
|
||||
} from "date-fns";
|
||||
import parse from "parse-duration";
|
||||
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { Button, LinkButton } from "../../primitives/Buttons";
|
||||
import { filterIcon } from "./RunFilters";
|
||||
|
||||
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
@@ -95,6 +90,10 @@ const timePeriods = [
|
||||
label: "3 days",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "5 days",
|
||||
value: "5d",
|
||||
},
|
||||
{
|
||||
label: "7 days",
|
||||
value: "7d",
|
||||
@@ -106,11 +105,7 @@ const timePeriods = [
|
||||
{
|
||||
label: "30 days",
|
||||
value: "30d",
|
||||
},
|
||||
{
|
||||
label: "90 days",
|
||||
value: "90d",
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const timeUnits = [
|
||||
@@ -128,6 +123,22 @@ function parsePeriodString(period: string): { value: number; unit: string } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
// Convert a period string to days using parse-duration
|
||||
function periodToDays(period: string): number {
|
||||
const ms = parse(period);
|
||||
if (!ms) return 0;
|
||||
return ms / MS_PER_DAY;
|
||||
}
|
||||
|
||||
// Calculate the number of days a date range spans from now
|
||||
function dateRangeToDays(from?: Date): number {
|
||||
if (!from) return 0;
|
||||
const now = new Date();
|
||||
return Math.ceil((now.getTime() - from.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
const DEFAULT_PERIOD = "7d";
|
||||
const defaultPeriodMs = parse(DEFAULT_PERIOD);
|
||||
if (!defaultPeriodMs) {
|
||||
@@ -292,6 +303,8 @@ export interface TimeFilterProps {
|
||||
applyShortcut?: ShortcutDefinition | undefined;
|
||||
/** Callback when the user applies a time filter selection, receives the applied values */
|
||||
onValueChange?: (values: TimeFilterApplyValues) => void;
|
||||
/** When set an upgrade message will be shown if you select a period further back than this number of days */
|
||||
maxPeriodDays?: number;
|
||||
}
|
||||
|
||||
export function TimeFilter({
|
||||
@@ -303,6 +316,7 @@ export function TimeFilter({
|
||||
hideLabel = false,
|
||||
applyShortcut,
|
||||
onValueChange,
|
||||
maxPeriodDays,
|
||||
}: TimeFilterProps = {}) {
|
||||
const { value } = useSearchParams();
|
||||
const periodValue = period ?? value("period");
|
||||
@@ -339,6 +353,7 @@ export function TimeFilter({
|
||||
labelName={labelName}
|
||||
applyShortcut={applyShortcut}
|
||||
onValueChange={onValueChange}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
@@ -356,6 +371,8 @@ function getInitialCustomDuration(period?: string): { value: string; unit: strin
|
||||
return { value: "", unit: "m" };
|
||||
}
|
||||
|
||||
type SectionType = "duration" | "dateRange";
|
||||
|
||||
export function TimeDropdown({
|
||||
trigger,
|
||||
period,
|
||||
@@ -366,6 +383,7 @@ export function TimeDropdown({
|
||||
applyShortcut,
|
||||
onApply,
|
||||
onValueChange,
|
||||
maxPeriodDays,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
period?: string;
|
||||
@@ -377,14 +395,16 @@ export function TimeDropdown({
|
||||
onApply?: (values: TimeFilterApplyValues) => void;
|
||||
/** When provided, the component operates in controlled mode and skips URL navigation */
|
||||
onValueChange?: (values: TimeFilterApplyValues) => void;
|
||||
/** When set an upgrade message will be shown if you select a period further back than this number of days */
|
||||
maxPeriodDays?: number;
|
||||
}) {
|
||||
const organization = useOptionalOrganization();
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { replace } = useSearchParams();
|
||||
const [fromValue, setFromValue] = useState(from);
|
||||
const [toValue, setToValue] = useState(to);
|
||||
|
||||
// Section selection state: "duration" or "dateRange"
|
||||
type SectionType = "duration" | "dateRange";
|
||||
const initialSection: SectionType = from || to ? "dateRange" : "duration";
|
||||
const [activeSection, setActiveSection] = useState<SectionType>(initialSection);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -418,9 +438,28 @@ export function TimeDropdown({
|
||||
return !isNaN(value) && value > 0;
|
||||
})();
|
||||
|
||||
// Calculate if the current selection exceeds maxPeriodDays
|
||||
const exceedsMaxPeriod = (() => {
|
||||
if (!maxPeriodDays) return false;
|
||||
|
||||
if (activeSection === "duration") {
|
||||
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
|
||||
if (!periodToCheck) return false;
|
||||
return periodToDays(periodToCheck) > maxPeriodDays;
|
||||
} else {
|
||||
// For date range, check if fromValue is further back than maxPeriodDays
|
||||
return dateRangeToDays(fromValue) > maxPeriodDays;
|
||||
}
|
||||
})();
|
||||
|
||||
const applySelection = useCallback(() => {
|
||||
setValidationError(null);
|
||||
|
||||
if (exceedsMaxPeriod) {
|
||||
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "duration") {
|
||||
// Validate custom duration
|
||||
if (selectedPeriod === "custom" && !isCustomDurationValid) {
|
||||
@@ -498,6 +537,8 @@ export function TimeDropdown({
|
||||
replace,
|
||||
onApply,
|
||||
onValueChange,
|
||||
exceedsMaxPeriod,
|
||||
maxPeriodDays
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -683,7 +724,7 @@ export function TimeDropdown({
|
||||
/>
|
||||
</div>
|
||||
{/* Quick select date ranges */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<QuickDateButton
|
||||
label="Yesterday"
|
||||
isActive={selectedQuickDate === "yesterday"}
|
||||
@@ -702,45 +743,26 @@ export function TimeDropdown({
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
setFromValue(startOfDay(today));
|
||||
setToValue(today);
|
||||
setToValue(endOfDay(today));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("today");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<QuickDateButton
|
||||
label="This week"
|
||||
isActive={selectedQuickDate === "thisWeek"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfWeek(now, { weekStartsOn: 1 }));
|
||||
setToValue(now);
|
||||
setToValue(endOfWeek(now, { weekStartsOn: 1 }));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("thisWeek");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last weekend"
|
||||
isActive={selectedQuickDate === "lastWeekend"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
let saturday: Date;
|
||||
if (isSaturday(now)) {
|
||||
saturday = subDays(now, 7);
|
||||
} else if (isSunday(now)) {
|
||||
saturday = subDays(now, 8);
|
||||
} else {
|
||||
saturday = previousSaturday(now);
|
||||
}
|
||||
const sunday = endOfDay(subDays(saturday, -1));
|
||||
setFromValue(startOfDay(saturday));
|
||||
setToValue(sunday);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastWeekend");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last week"
|
||||
isActive={selectedQuickDate === "lastWeek"}
|
||||
@@ -753,56 +775,18 @@ export function TimeDropdown({
|
||||
setSelectedQuickDate("lastWeek");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last weekdays"
|
||||
isActive={selectedQuickDate === "lastWeekdays"}
|
||||
onClick={() => {
|
||||
const lastWeek = subWeeks(new Date(), 1);
|
||||
const monday = startOfWeek(lastWeek, { weekStartsOn: 1 });
|
||||
const friday = endOfDay(subDays(monday, -4)); // Monday + 4 days = Friday
|
||||
setFromValue(startOfDay(monday));
|
||||
setToValue(friday);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastWeekdays");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last month"
|
||||
isActive={selectedQuickDate === "lastMonth"}
|
||||
onClick={() => {
|
||||
const lastMonth = subMonths(new Date(), 1);
|
||||
setFromValue(startOfMonth(lastMonth));
|
||||
setToValue(endOfMonth(lastMonth));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastMonth");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="This month"
|
||||
isActive={selectedQuickDate === "thisMonth"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfMonth(now));
|
||||
setToValue(now);
|
||||
setToValue(endOfMonth(now));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("thisMonth");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Year to date"
|
||||
isActive={selectedQuickDate === "yearToDate"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfYear(now));
|
||||
setToValue(now);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("yearToDate");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{validationError && activeSection === "dateRange" && (
|
||||
<Paragraph variant="extra-small" className="mt-2 text-error">
|
||||
@@ -812,6 +796,17 @@ export function TimeDropdown({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade callout when exceeding maxPeriodDays */}
|
||||
{exceedsMaxPeriod && organization && (
|
||||
<Callout
|
||||
variant="pricing"
|
||||
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
|
||||
className="items-center"
|
||||
>
|
||||
{simplur`Your plan allows a maximum of ${maxPeriodDays} day[|s].`}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
|
||||
<Button
|
||||
@@ -839,6 +834,7 @@ export function TimeDropdown({
|
||||
applySelection();
|
||||
}}
|
||||
type="button"
|
||||
disabled={exceedsMaxPeriod}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
|
||||
@@ -521,7 +521,6 @@ const EnvironmentSchema = z
|
||||
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||||
|
||||
CENTS_PER_RUN: z.coerce.number().default(0),
|
||||
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
|
||||
|
||||
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
|
||||
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
|
||||
@@ -1191,12 +1190,16 @@ const EnvironmentSchema = z
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
|
||||
|
||||
// Query feature flag
|
||||
QUERY_FEATURE_ENABLED: z.string().default("1"),
|
||||
|
||||
// Query page ClickHouse limits (for TSQL queries)
|
||||
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
|
||||
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
|
||||
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||||
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||||
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: z.coerce.number().int().default(0),
|
||||
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: z.coerce.number().int().default(10_000),
|
||||
|
||||
// Query page concurrency limits
|
||||
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { type loader as canViewLogsPageLoader } from "~/routes/resources.orgs.$organizationSlug.can-view-logs-page/route";
|
||||
|
||||
export function useCanViewLogsPage(): boolean | undefined {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useTypedFetcher<typeof canViewLogsPageLoader>();
|
||||
|
||||
useEffect(() => {
|
||||
const url = `/resources/orgs/${organization.slug}/can-view-logs-page`;
|
||||
fetcher.load(url);
|
||||
}, [organization.slug]);
|
||||
|
||||
return fetcher.data?.canViewLogsPage;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "./SelectBestEnvironmentPresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -153,18 +153,24 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Get global feature flags (no overrides or defaults)
|
||||
const globalFlags = await flags();
|
||||
|
||||
return orgs.map((org) => {
|
||||
const flagsResult = org.featureFlags
|
||||
const orgFlagsResult = org.featureFlags
|
||||
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
|
||||
: ({ success: false } as const);
|
||||
const flags = flagsResult.success ? flagsResult.data : {};
|
||||
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
|
||||
|
||||
// Combine global flags with org flags (org flags win)
|
||||
const combinedFlags = { ...globalFlags, ...orgFlags };
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
title: org.title,
|
||||
avatar: parseAvatar(org.avatar, defaultAvatar),
|
||||
featureFlags: flags,
|
||||
featureFlags: combinedFlags,
|
||||
projects: org.projects.map((project) => ({
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
|
||||
@@ -36,10 +36,7 @@ export class LogDetailPresenter {
|
||||
);
|
||||
}
|
||||
|
||||
const isClickhouseV2 = store === "clickhouse_v2";
|
||||
const queryBuilder = isClickhouseV2
|
||||
? this.clickhouse.taskEventsV2.logDetailQueryBuilder()
|
||||
: this.clickhouse.taskEvents.logDetailQueryBuilder();
|
||||
const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
|
||||
|
||||
// Required filters - spanId, traceId, and startTime uniquely identify the log
|
||||
// Multiple events can share the same spanId (span, span events, logs), so startTime is needed
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { type ClickHouse, type LogsListResult } from "@internal/clickhouse";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { type ClickHouse } from "@internal/clickhouse";
|
||||
import {
|
||||
type PrismaClientOrTransaction,
|
||||
type TaskRunStatus,
|
||||
TaskRunStatus as TaskRunStatusEnum,
|
||||
} from "@trigger.dev/database";
|
||||
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
|
||||
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
|
||||
|
||||
// Create a schema that validates TaskRunStatus enum values
|
||||
const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum));
|
||||
import parseDuration from "parse-duration";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
|
||||
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
|
||||
@@ -33,27 +27,27 @@ type ErrorAttributes = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function escapeClickHouseString(val: string): string {
|
||||
return val
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\//g, "\\/")
|
||||
.replace(/%/g, "\\%")
|
||||
.replace(/_/g, "\\_");
|
||||
}
|
||||
|
||||
|
||||
export type LogsListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
// filters
|
||||
tasks?: string[];
|
||||
versions?: string[];
|
||||
statuses?: TaskRunStatus[];
|
||||
tags?: string[];
|
||||
scheduleId?: string;
|
||||
runId?: string;
|
||||
period?: string;
|
||||
bulkId?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
isTest?: boolean;
|
||||
rootOnly?: boolean;
|
||||
batchId?: string;
|
||||
runId?: string[];
|
||||
queues?: string[];
|
||||
machines?: MachinePresetName[];
|
||||
levels?: LogLevel[];
|
||||
defaultPeriod?: string;
|
||||
retentionLimitDays?: number;
|
||||
// search
|
||||
search?: string;
|
||||
includeDebugLogs?: boolean;
|
||||
@@ -67,22 +61,13 @@ export const LogsListOptionsSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
tasks: z.array(z.string()).optional(),
|
||||
versions: z.array(z.string()).optional(),
|
||||
statuses: TaskRunStatusSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
period: z.string().optional(),
|
||||
bulkId: z.string().optional(),
|
||||
from: z.number().int().nonnegative().optional(),
|
||||
to: z.number().int().nonnegative().optional(),
|
||||
isTest: z.boolean().optional(),
|
||||
rootOnly: z.boolean().optional(),
|
||||
batchId: z.string().optional(),
|
||||
runId: z.array(z.string()).optional(),
|
||||
queues: z.array(z.string()).optional(),
|
||||
machines: z.array(MachinePresetName).optional(),
|
||||
levels: z.array(LogLevelSchema).optional(),
|
||||
defaultPeriod: z.string().optional(),
|
||||
retentionLimitDays: z.number().int().positive().optional(),
|
||||
search: z.string().max(1000).optional(),
|
||||
includeDebugLogs: z.boolean().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
@@ -91,7 +76,6 @@ export const LogsListOptionsSchema = z.object({
|
||||
});
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_RUN_IDS = 5000;
|
||||
|
||||
export type LogsList = Awaited<ReturnType<LogsListPresenter["call"]>>;
|
||||
export type LogEntry = LogsList["logs"][0];
|
||||
@@ -99,17 +83,15 @@ export type LogsListAppliedFilters = LogsList["filters"];
|
||||
|
||||
// Cursor is a base64 encoded JSON of the pagination keys
|
||||
type LogCursor = {
|
||||
startTime: string;
|
||||
environmentId: string;
|
||||
unixTimestamp: number;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
runId: string;
|
||||
};
|
||||
|
||||
const LogCursorSchema = z.object({
|
||||
startTime: z.string(),
|
||||
environmentId: z.string(),
|
||||
unixTimestamp: z.number(),
|
||||
traceId: z.string(),
|
||||
spanId: z.string(),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
function encodeCursor(cursor: LogCursor): string {
|
||||
@@ -141,10 +123,6 @@ function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?
|
||||
return { kinds: ["LOG_WARN"] };
|
||||
case "ERROR":
|
||||
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
|
||||
case "CANCELLED":
|
||||
return { statuses: ["CANCELLED"] };
|
||||
case "TRACE":
|
||||
return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,18 +158,8 @@ export class LogsListPresenter extends BasePresenter {
|
||||
userId,
|
||||
projectId,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
tags,
|
||||
scheduleId,
|
||||
period,
|
||||
bulkId,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
queues,
|
||||
machines,
|
||||
period,
|
||||
levels,
|
||||
search,
|
||||
from,
|
||||
@@ -200,6 +168,7 @@ export class LogsListPresenter extends BasePresenter {
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
includeDebugLogs = true,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
}: LogsListOptions
|
||||
) {
|
||||
const time = timeFilters({
|
||||
@@ -220,23 +189,20 @@ export class LogsListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
const hasRunLevelFilters =
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
(bulkId !== undefined && bulkId !== "") ||
|
||||
(scheduleId !== undefined && scheduleId !== "") ||
|
||||
(tags !== undefined && tags.length > 0) ||
|
||||
batchId !== undefined ||
|
||||
(runId !== undefined && runId.length > 0) ||
|
||||
(queues !== undefined && queues.length > 0) ||
|
||||
(machines !== undefined && machines.length > 0) ||
|
||||
typeof isTest === "boolean" ||
|
||||
rootOnly === true;
|
||||
// Apply retention limit if provided
|
||||
let wasClampedByRetention = false;
|
||||
if (retentionLimitDays !== undefined && effectiveFrom) {
|
||||
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (effectiveFrom < retentionCutoffDate) {
|
||||
effectiveFrom = retentionCutoffDate;
|
||||
wasClampedByRetention = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters =
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
hasRunLevelFilters ||
|
||||
(runId !== undefined && runId !== "") ||
|
||||
(levels !== undefined && levels.length > 0) ||
|
||||
(search !== undefined && search !== "") ||
|
||||
!time.isDefault;
|
||||
@@ -266,120 +232,29 @@ export class LogsListPresenter extends BasePresenter {
|
||||
findDisplayableEnvironment(environmentId, userId),
|
||||
]);
|
||||
|
||||
if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
|
||||
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
|
||||
select: {
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: bulkId,
|
||||
projectId,
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedBulkAction) {
|
||||
bulkActions.push(selectedBulkAction);
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
// If we have run-level filters, we need to first get matching run IDs from Postgres
|
||||
let runIds: string[] | undefined;
|
||||
if (hasRunLevelFilters) {
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica,
|
||||
});
|
||||
|
||||
function clampToNow(date: Date): Date {
|
||||
const now = new Date();
|
||||
return date > now ? now : date;
|
||||
}
|
||||
|
||||
runIds = await runsRepository.listFriendlyRunIds({
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
tags,
|
||||
scheduleId,
|
||||
period,
|
||||
from: effectiveFrom ? effectiveFrom.getTime() : undefined,
|
||||
to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
bulkId,
|
||||
queues,
|
||||
machines,
|
||||
page: {
|
||||
size: MAX_RUN_IDS,
|
||||
direction: "forward",
|
||||
},
|
||||
});
|
||||
|
||||
if (runIds.length === 0) {
|
||||
return {
|
||||
logs: [],
|
||||
pagination: {
|
||||
next: undefined,
|
||||
previous: undefined,
|
||||
},
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({
|
||||
slug: task.slug,
|
||||
triggerSource: task.triggerSource,
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
bulkActions: bulkActions.map((bulkAction) => ({
|
||||
id: bulkAction.friendlyId,
|
||||
type: bulkAction.type,
|
||||
createdAt: bulkAction.createdAt,
|
||||
name: bulkAction.name || bulkAction.friendlyId,
|
||||
})),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
statuses: statuses || [],
|
||||
levels: levels || [],
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
},
|
||||
hasFilters,
|
||||
hasAnyLogs: false,
|
||||
searchTerm: search,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which store to use based on organization configuration
|
||||
const { store } = await getConfiguredEventRepository(organizationId);
|
||||
|
||||
// Throw error if postgres is detected
|
||||
if (store === "postgres") {
|
||||
if (store === EVENT_STORE_TYPES.POSTGRES) {
|
||||
throw new ServiceValidationError(
|
||||
"Logs are not available for PostgreSQL event store. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
// Get the appropriate query builder based on store type
|
||||
const isClickhouseV2 = store === "clickhouse_v2";
|
||||
if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
|
||||
throw new ServiceValidationError(
|
||||
"Logs are not available for ClickHouse event store. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
const queryBuilder = isClickhouseV2
|
||||
? this.clickhouse.taskEventsV2.logsListQueryBuilder()
|
||||
: this.clickhouse.taskEvents.logsListQueryBuilder();
|
||||
const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
|
||||
|
||||
queryBuilder.prewhere("environment_id = {environmentId: String}", {
|
||||
queryBuilder.where("environment_id = {environmentId: String}", {
|
||||
environmentId,
|
||||
});
|
||||
|
||||
@@ -388,16 +263,13 @@ export class LogsListPresenter extends BasePresenter {
|
||||
});
|
||||
queryBuilder.where("project_id = {projectId: String}", { projectId });
|
||||
|
||||
// Time filters - inserted_at in PREWHERE only for v2, start_time in WHERE for both
|
||||
|
||||
if (effectiveFrom) {
|
||||
const fromNs = convertDateToNanoseconds(effectiveFrom);
|
||||
|
||||
// Only use inserted_at for partition pruning if v2
|
||||
if (isClickhouseV2) {
|
||||
queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.where("start_time >= {fromTime: String}", {
|
||||
fromTime: formatNanosecondsForClickhouse(fromNs),
|
||||
@@ -408,12 +280,9 @@ export class LogsListPresenter extends BasePresenter {
|
||||
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
|
||||
const toNs = convertDateToNanoseconds(clampedTo);
|
||||
|
||||
// Only use inserted_at for partition pruning if v2
|
||||
if (isClickhouseV2) {
|
||||
queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
|
||||
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
|
||||
});
|
||||
}
|
||||
queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
|
||||
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
|
||||
});
|
||||
|
||||
queryBuilder.where("start_time <= {toTime: String}", {
|
||||
toTime: formatNanosecondsForClickhouse(toNs),
|
||||
@@ -427,19 +296,18 @@ export class LogsListPresenter extends BasePresenter {
|
||||
});
|
||||
}
|
||||
|
||||
// Run IDs filter (from Postgres lookup)
|
||||
if (runIds && runIds.length > 0) {
|
||||
queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds });
|
||||
// Run ID filter
|
||||
if (runId && runId !== "") {
|
||||
queryBuilder.where("run_id = {runId: String}", { runId });
|
||||
}
|
||||
|
||||
// Case-insensitive search in message, attributes, and status fields
|
||||
if (search && search.trim() !== "") {
|
||||
const searchTerm = search.trim();
|
||||
const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
|
||||
queryBuilder.where(
|
||||
"(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})",
|
||||
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
|
||||
{
|
||||
searchPattern: `%${searchTerm}%`,
|
||||
statusTerm: searchTerm.toUpperCase(),
|
||||
searchPattern: `%${searchTerm}%`
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -447,7 +315,6 @@ export class LogsListPresenter extends BasePresenter {
|
||||
if (levels && levels.length > 0) {
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, string[]> = {};
|
||||
const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED");
|
||||
|
||||
for (const level of levels) {
|
||||
const filter = levelToKindsAndStatuses(level);
|
||||
@@ -457,11 +324,10 @@ export class LogsListPresenter extends BasePresenter {
|
||||
const kindsKey = `kinds_${level}`;
|
||||
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
|
||||
|
||||
// For TRACE: exclude error/cancelled traces if ERROR/CANCELLED not explicitly selected
|
||||
if (level === "TRACE" && !hasErrorOrCancelledLevel) {
|
||||
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
|
||||
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
|
||||
}
|
||||
|
||||
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
|
||||
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
|
||||
|
||||
|
||||
levelConditions.push(kindCondition);
|
||||
params[kindsKey] = filter.kinds;
|
||||
@@ -486,28 +352,34 @@ export class LogsListPresenter extends BasePresenter {
|
||||
// Debug logs are available only to admins
|
||||
if (includeDebugLogs === false) {
|
||||
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
|
||||
debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"],
|
||||
debugKinds: ["DEBUG_EVENT"],
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", {
|
||||
debugSpans: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"],
|
||||
});
|
||||
|
||||
// kindCondition += ` `;
|
||||
// params["excluded_statuses"] = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"];
|
||||
|
||||
|
||||
queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
|
||||
|
||||
// Cursor pagination
|
||||
const decodedCursor = cursor ? decodeCursor(cursor) : null;
|
||||
if (decodedCursor) {
|
||||
queryBuilder.where(
|
||||
"(start_time, trace_id, span_id, run_id) < ({cursorStartTime: String}, {cursorTraceId: String}, {cursorSpanId: String}, {cursorRunId: String})",
|
||||
"(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
|
||||
{
|
||||
cursorStartTime: decodedCursor.startTime,
|
||||
cursorEnvId: decodedCursor.environmentId,
|
||||
cursorUnixTimestamp: decodedCursor.unixTimestamp,
|
||||
cursorTraceId: decodedCursor.traceId,
|
||||
cursorSpanId: decodedCursor.spanId,
|
||||
cursorRunId: decodedCursor.runId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC");
|
||||
|
||||
queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
|
||||
// Limit + 1 to check if there are more results
|
||||
queryBuilder.limit(pageSize + 1);
|
||||
|
||||
@@ -525,11 +397,11 @@ export class LogsListPresenter extends BasePresenter {
|
||||
let nextCursor: string | undefined;
|
||||
if (hasMore && logs.length > 0) {
|
||||
const lastLog = logs[logs.length - 1];
|
||||
const unixTimestamp = Math.floor(new Date(lastLog.start_time).getTime() / 1000);
|
||||
nextCursor = encodeCursor({
|
||||
startTime: lastLog.start_time,
|
||||
environmentId,
|
||||
unixTimestamp,
|
||||
traceId: lastLog.trace_id,
|
||||
spanId: lastLog.span_id,
|
||||
runId: lastLog.run_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,8 +459,6 @@ export class LogsListPresenter extends BasePresenter {
|
||||
})),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
statuses: statuses || [],
|
||||
levels: levels || [],
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
@@ -596,6 +466,10 @@ export class LogsListPresenter extends BasePresenter {
|
||||
hasFilters,
|
||||
hasAnyLogs: transformedLogs.length > 0,
|
||||
searchTerm: search,
|
||||
retention: retentionLimitDays !== undefined ? {
|
||||
limitDays: retentionLimitDays,
|
||||
wasClamped: wasClampedByRetention,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type QueryHistoryItem = {
|
||||
scope: QueryScope;
|
||||
createdAt: Date;
|
||||
userName: string | null;
|
||||
/** AI-generated title summarizing the query */
|
||||
title: string | null;
|
||||
/** Time filter settings */
|
||||
filterPeriod: string | null;
|
||||
filterFrom: Date | null;
|
||||
@@ -24,6 +26,7 @@ export class QueryPresenter extends BasePresenter {
|
||||
id: true,
|
||||
query: true,
|
||||
scope: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
filterPeriod: true,
|
||||
filterFrom: true,
|
||||
@@ -43,6 +46,7 @@ export class QueryPresenter extends BasePresenter {
|
||||
scope: q.scope.toLowerCase() as QueryScope,
|
||||
createdAt: q.createdAt,
|
||||
userName: q.user?.displayName ?? q.user?.name ?? null,
|
||||
title: q.title,
|
||||
filterPeriod: q.filterPeriod,
|
||||
filterFrom: q.filterFrom,
|
||||
filterTo: q.filterTo,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
@@ -48,7 +48,7 @@ export class RegionsPresenter extends BasePresenter {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const getFlag = makeFlags(this._replica);
|
||||
const getFlag = makeFlag(this._replica);
|
||||
const defaultWorkerInstanceGroupId = await getFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { createSSELoader } from "~/utils/sse";
|
||||
import { createSSELoader, SendFunction } from "~/utils/sse";
|
||||
import { throttle } from "~/utils/throttle";
|
||||
import { tracePubSub } from "~/v3/services/tracePubSub.server";
|
||||
|
||||
const PING_INTERVAL = 1000;
|
||||
const STREAM_TIMEOUT = 30 * 1000; // 30 seconds
|
||||
const PING_INTERVAL = 5_000;
|
||||
const STREAM_TIMEOUT = 30_000;
|
||||
|
||||
export class RunStreamPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -49,36 +49,40 @@ export class RunStreamPresenter {
|
||||
// Subscribe to trace updates
|
||||
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
|
||||
|
||||
// Store throttled send function and message listener for cleanup
|
||||
let throttledSend: ReturnType<typeof throttle> | undefined;
|
||||
// Only send max every 1 second
|
||||
const throttledSend = throttle(
|
||||
(args: { send: SendFunction; event?: string; data: string }) => {
|
||||
try {
|
||||
args.send({ event: args.event, data: args.data });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE in RunStreamPresenter", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Abort the stream on send error
|
||||
context.controller.abort("Send error");
|
||||
}
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
let messageListener: ((event: string) => void) | undefined;
|
||||
|
||||
return {
|
||||
initStream: ({ send }) => {
|
||||
// Create throttled send function
|
||||
throttledSend = throttle((args: { event?: string; data: string }) => {
|
||||
try {
|
||||
send(args);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE in RunStreamPresenter", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Abort the stream on send error
|
||||
context.controller.abort("Send error");
|
||||
}
|
||||
}, 1000);
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
|
||||
// Set up message listener for pub/sub events
|
||||
messageListener = (event: string) => {
|
||||
throttledSend?.({ data: event });
|
||||
throttledSend({ send, event: "message", data: event });
|
||||
};
|
||||
eventEmitter.addListener("message", messageListener);
|
||||
|
||||
@@ -88,7 +92,8 @@ export class RunStreamPresenter {
|
||||
iterator: ({ send }) => {
|
||||
// Send ping to keep connection alive
|
||||
try {
|
||||
send({ event: "ping", data: new Date().toISOString() });
|
||||
// Send an actual message so the client refreshes
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
// If we can't send a ping, the connection is likely dead
|
||||
return false;
|
||||
|
||||
+253
-133
@@ -1,5 +1,6 @@
|
||||
import { type LoaderFunctionArgs , redirect} from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useFetcher, useNavigation, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import {
|
||||
TypedAwait,
|
||||
@@ -8,41 +9,41 @@ import {
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
|
||||
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import type { LogLevel } from "~/utils/logUtils";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import {
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { RunsFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { LogsTable } from "~/components/logs/LogsTable";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { LogDetailView } from "~/components/logs/LogDetailView";
|
||||
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
|
||||
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
|
||||
// Valid log levels for filtering
|
||||
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
|
||||
@@ -58,6 +59,7 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
// TODO: Move this to a more appropriate shared location
|
||||
async function hasLogsPageAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
@@ -97,7 +99,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = user.id;
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasLogsPageAccess(
|
||||
@@ -121,53 +122,58 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
// Get search term, levels, and showDebug from query params
|
||||
// Get filters from query params
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const runId = url.searchParams.get("runId") ?? undefined;
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const levels = parseLevelsFromUrl(url);
|
||||
const showDebug = url.searchParams.get("showDebug") === "true";
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
const from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
const to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
|
||||
// Get the user's plan to determine log retention limit
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const presenter = new LogsListPresenter($replica, clickhouseClient);
|
||||
|
||||
const listPromise = presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
search,
|
||||
levels,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
}).catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: "Failed to load logs. Please refresh and try again." };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
|
||||
const cookieValue = await uiPreferencesStorage.commitSession(session);
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: listPromise,
|
||||
rootOnlyDefault: filters.rootOnly,
|
||||
filters,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
const listPromise = presenter
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
runId,
|
||||
search,
|
||||
levels,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": cookieValue,
|
||||
},
|
||||
}
|
||||
);
|
||||
retentionLimitDays,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: listPromise,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
defaultPeriod: "1h",
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data, rootOnlyDefault, isAdmin, showDebug, defaultPeriod } = useTypedLoaderData<typeof loader>();
|
||||
const { data, isAdmin, showDebug, defaultPeriod } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -183,7 +189,7 @@ export default function Page() {
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading logs</Paragraph>
|
||||
<Paragraph variant="small">Loading logs…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,10 +198,17 @@ export default function Page() {
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -203,21 +216,35 @@ export default function Page() {
|
||||
// Check if result contains an error
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LogsList
|
||||
list={result}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
list={result}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<LogsList
|
||||
list={result}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
@@ -227,15 +254,117 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
function RetentionNotice({
|
||||
logCount,
|
||||
retentionDays,
|
||||
}: {
|
||||
logCount: number;
|
||||
retentionDays: number;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="extra-small" className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span className="text-text-dimmed">
|
||||
Showing last {retentionDays} {retentionDays === 1 ? 'day' : 'days'}
|
||||
</span>
|
||||
<a
|
||||
href="https://trigger.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-link hover:underline"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersBar({
|
||||
list,
|
||||
rootOnlyDefault,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
defaultPeriod,
|
||||
}: {
|
||||
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
|
||||
isAdmin: boolean;
|
||||
showDebug: boolean;
|
||||
defaultPeriod?: string;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("runId") ||
|
||||
searchParams.has("search") ||
|
||||
searchParams.has("levels") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
const handleDebugToggle = useCallback((checked: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (checked) {
|
||||
url.searchParams.set("showDebug", "true");
|
||||
} else {
|
||||
url.searchParams.delete("showDebug");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
{list ? (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={list.possibleTasks} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} />
|
||||
<LogsLevelFilter/>
|
||||
<LogsSearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={[]} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} />
|
||||
<LogsLevelFilter/>
|
||||
<LogsSearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{list?.retention?.wasClamped && (
|
||||
<RetentionNotice
|
||||
logCount={list.logs.length}
|
||||
retentionDays={list.retention.limitDays}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Debug"
|
||||
checked={showDebug}
|
||||
onCheckedChange={handleDebugToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
list,
|
||||
}: {
|
||||
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
|
||||
rootOnlyDefault: boolean;
|
||||
isAdmin: boolean;
|
||||
showDebug: boolean;
|
||||
defaultPeriod?: string;
|
||||
@@ -253,37 +382,50 @@ function LogsList({
|
||||
// Selected log state - managed locally to avoid triggering navigation
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
|
||||
|
||||
const handleDebugToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (checked) {
|
||||
url.searchParams.set("showDebug", "true");
|
||||
} else {
|
||||
url.searchParams.delete("showDebug");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Track which filter state (search params) the current fetcher request corresponds to
|
||||
const fetcherFilterStateRef = useRef<string>(location.search);
|
||||
|
||||
// Clear accumulated logs immediately when filters change (for instant visual feedback)
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs([]);
|
||||
setNextCursor(undefined);
|
||||
// Close side panel when filters change to avoid showing a log that's no longer visible
|
||||
setSelectedLogId(undefined);
|
||||
}, [location.search]);
|
||||
|
||||
// Reset accumulated logs when the initial list changes (e.g., filters change)
|
||||
// Populate accumulated logs when new data arrives
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs(list.logs);
|
||||
setNextCursor(list.pagination.next);
|
||||
}, [list.logs, list.pagination.next]);
|
||||
|
||||
// Clear log parameter from URL when selectedLogId is cleared
|
||||
useEffect(() => {
|
||||
if (!selectedLogId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("log")) {
|
||||
url.searchParams.delete("log");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}
|
||||
}, [selectedLogId]);
|
||||
|
||||
// Append new logs when fetcher completes (with deduplication)
|
||||
useEffect(() => {
|
||||
if (fetcher.data && fetcher.state === "idle") {
|
||||
// Ignore fetcher data if it was loaded for a different filter state
|
||||
if (fetcherFilterStateRef.current !== location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set(accumulatedLogs.map((log) => log.id));
|
||||
const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id));
|
||||
if (newLogs.length > 0) {
|
||||
setAccumulatedLogs((prev) => [...prev, ...newLogs]);
|
||||
setNextCursor(fetcher.data.pagination.next);
|
||||
}
|
||||
setNextCursor(fetcher.data.pagination.next);
|
||||
}
|
||||
}, [fetcher.data, fetcher.state, accumulatedLogs]);
|
||||
}, [fetcher.data, fetcher.state, accumulatedLogs, location.search]);
|
||||
|
||||
// Build resource URL for loading more
|
||||
const loadMoreUrl = useMemo(() => {
|
||||
@@ -297,27 +439,26 @@ function LogsList({
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loadMoreUrl && fetcher.state === "idle") {
|
||||
// Store the current filter state before loading
|
||||
fetcherFilterStateRef.current = location.search;
|
||||
fetcher.load(loadMoreUrl);
|
||||
}
|
||||
}, [loadMoreUrl, fetcher]);
|
||||
}, [loadMoreUrl, fetcher, location.search]);
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogId) return undefined;
|
||||
return accumulatedLogs.find((log) => log.id === selectedLogId);
|
||||
}, [selectedLogId, accumulatedLogs]);
|
||||
|
||||
const updateUrlWithLog = useCallback(
|
||||
(logId: string | undefined) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (logId) {
|
||||
url.searchParams.set("log", logId);
|
||||
} else {
|
||||
url.searchParams.delete("log");
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
},
|
||||
[]
|
||||
);
|
||||
const updateUrlWithLog = useCallback((logId: string | undefined) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (logId) {
|
||||
url.searchParams.set("log", logId);
|
||||
} else {
|
||||
url.searchParams.delete("log");
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}, []);
|
||||
|
||||
const handleLogSelect = useCallback(
|
||||
(logId: string) => {
|
||||
@@ -339,51 +480,30 @@ function LogsList({
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="logs-main" min="200px">
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Filters */}
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<RunsFilters
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
hideSearch
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<LogsLevelFilter showDebug={showDebug} />
|
||||
<LogsSearchInput />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Debug"
|
||||
checked={showDebug}
|
||||
onCheckedChange={handleDebugToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<LogsTable
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</div>
|
||||
<LogsTable
|
||||
key={location.search}
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Side panel for log details */}
|
||||
{selectedLogId && (
|
||||
<>
|
||||
<ResizableHandle id="logs-handle" />
|
||||
<ResizablePanel id="log-detail" min="300px" default="430px" max="600px" isStaticAtRest>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Spinner /></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LogDetailView
|
||||
logId={selectedLogId}
|
||||
initialLog={selectedLog}
|
||||
|
||||
+1
-2
@@ -48,7 +48,7 @@ LIMIT 20`,
|
||||
total_cost,
|
||||
usage_duration,
|
||||
machine,
|
||||
created_at
|
||||
triggered_at
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
ORDER BY total_cost DESC
|
||||
@@ -79,4 +79,3 @@ export function ExamplesContent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+59
-30
@@ -36,53 +36,82 @@ export function QueryHelpSidebar({
|
||||
onValueChange={onTabChange}
|
||||
className="flex min-h-0 flex-col overflow-hidden pt-1"
|
||||
>
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger value="ai" variant="underline" layoutId="query-help-tabs">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="guide" variant="underline" layoutId="query-help-tabs">
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="schema" variant="underline" layoutId="query-help-tabs">
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="examples" variant="underline" layoutId="query-help-tabs">
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger
|
||||
value="ai"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="guide"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="schema"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="examples"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent
|
||||
value="ai"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
<div className="min-w-64 p-3">
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="guide"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
<div className="min-w-64 p-3">
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="schema"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<TableSchemaContent />
|
||||
<div className="min-w-64 p-3">
|
||||
<TableSchemaContent />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="examples"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
<div className="min-w-64 p-3">
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+23
-11
@@ -36,9 +36,14 @@ const SQL_KEYWORDS = [
|
||||
];
|
||||
|
||||
function highlightSQL(query: string): React.ReactNode[] {
|
||||
// Normalize whitespace for display (let CSS line-clamp handle truncation)
|
||||
const normalized = query.replace(/\s+/g, " ").slice(0, 200);
|
||||
const suffix = "";
|
||||
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
|
||||
// Then trim each line and limit total length
|
||||
const normalized = query
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n")
|
||||
.slice(0, 500);
|
||||
|
||||
// Create a regex pattern that matches keywords as whole words (case insensitive)
|
||||
const keywordPattern = new RegExp(
|
||||
@@ -69,10 +74,6 @@ function highlightSQL(query: string): React.ReactNode[] {
|
||||
parts.push(normalized.slice(lastIndex));
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
parts.push(suffix);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -118,10 +119,21 @@ export function QueryHistoryPopover({
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<p className="line-clamp-2 w-full break-words text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
|
||||
{item.title ? (
|
||||
<>
|
||||
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
<span className="capitalize">{item.scope}</span>
|
||||
{valueLabel && <span>· {valueLabel}</span>}
|
||||
|
||||
+432
-170
@@ -1,13 +1,23 @@
|
||||
import { ArrowDownTrayIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata, WhereClauseFallback } from "@internal/clickhouse";
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ClipboardIcon,
|
||||
TableCellsIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { type WhereClauseCondition } from "@internal/tsql";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import {
|
||||
redirect,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import parse from "parse-duration";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
|
||||
import simplur from "simplur";
|
||||
import { z } from "zod";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { AlphaTitle } from "~/components/AlphaBadge";
|
||||
@@ -21,9 +31,7 @@ import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor";
|
||||
import { TSQLResultsTable } from "~/components/code/TSQLResultsTable";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import {
|
||||
@@ -32,6 +40,7 @@ import {
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
@@ -49,27 +58,30 @@ import {
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
|
||||
import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title";
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { executeQuery, type QueryScope } from "~/services/queryService.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { canAccessQuery } from "~/v3/canAccessQuery.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { QueryHelpSidebar } from "./QueryHelpSidebar";
|
||||
import { QueryHistoryPopover } from "./QueryHistoryPopover";
|
||||
import type { AITimeFilter } from "./types";
|
||||
import { formatQueryStats } from "./utils";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import parse from "parse-duration";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogPortal, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogOverlay } from "@radix-ui/react-dialog";
|
||||
import { formatDurationNanoseconds } from "@trigger.dev/core/v3";
|
||||
|
||||
/** Convert a Date or ISO string to ISO string format */
|
||||
function toISOString(value: Date | string): string {
|
||||
@@ -79,40 +91,6 @@ function toISOString(value: Date | string): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
async function hasQueryAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
isImpersonating: boolean,
|
||||
organizationSlug: string
|
||||
): Promise<boolean> {
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check organization feature flags
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization?.featureFlags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flags = organization.featureFlags as Record<string, unknown>;
|
||||
const hasQueryAccessResult = validateFeatureFlagValue(
|
||||
FEATURE_FLAG.hasQueryAccess,
|
||||
flags.hasQueryAccess
|
||||
);
|
||||
|
||||
return hasQueryAccessResult.success && hasQueryAccessResult.data === true;
|
||||
}
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "environment", label: "Environment" },
|
||||
{ value: "project", label: "Project" },
|
||||
@@ -123,12 +101,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasQueryAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw redirect("/");
|
||||
}
|
||||
@@ -159,12 +137,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
defaultQuery,
|
||||
defaultPeriod: await getDefaultPeriod(project.organizationId),
|
||||
history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
});
|
||||
};
|
||||
|
||||
const DEFAULT_PERIOD = "7d";
|
||||
async function getDefaultPeriod(organizationId: string): Promise<string> {
|
||||
const idealDefaultPeriodDays = 7;
|
||||
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
|
||||
if (maxQueryPeriod < idealDefaultPeriodDays) {
|
||||
return `${maxQueryPeriod}d`;
|
||||
}
|
||||
return `${idealDefaultPeriodDays}d`;
|
||||
}
|
||||
|
||||
const ActionSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
@@ -179,12 +166,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasQueryAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
return typedjson(
|
||||
{
|
||||
@@ -193,8 +180,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
@@ -209,8 +198,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
@@ -225,8 +216,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
@@ -250,8 +243,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -263,31 +258,54 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const explain = explainParam === "true" && isAdmin;
|
||||
|
||||
// Build time filter fallback for triggered_at column
|
||||
const defaultPeriod = await getDefaultPeriod(project.organizationId);
|
||||
const timeFilter = timeFilters({
|
||||
period: period ?? undefined,
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
defaultPeriod: DEFAULT_PERIOD,
|
||||
defaultPeriod,
|
||||
});
|
||||
|
||||
let triggeredAtFallback: WhereClauseFallback;
|
||||
if (timeFilter.from && timeFilter.to) {
|
||||
// Both from and to specified - use BETWEEN
|
||||
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
|
||||
} else if (timeFilter.from) {
|
||||
// Only from specified
|
||||
triggeredAtFallback = { op: "gte", value: timeFilter.from };
|
||||
} else if (timeFilter.to) {
|
||||
// Only to specified
|
||||
triggeredAtFallback = { op: "lte", value: timeFilter.to };
|
||||
} else {
|
||||
// Calculate the effective "from" date the user is requesting (for period clipping check)
|
||||
// This is null only when the user specifies just a "to" date (rare case)
|
||||
let requestedFromDate: Date | null = null;
|
||||
if (timeFilter.from) {
|
||||
requestedFromDate = new Date(timeFilter.from);
|
||||
} else if (!timeFilter.to) {
|
||||
// Period specified (or default) - calculate from now
|
||||
const periodMs = parse(timeFilter.period ?? DEFAULT_PERIOD) ?? 7 * 24 * 60 * 60 * 1000;
|
||||
triggeredAtFallback = { op: "gte", value: new Date(Date.now() - periodMs) };
|
||||
const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000;
|
||||
requestedFromDate = new Date(Date.now() - periodMs);
|
||||
}
|
||||
|
||||
// Build the fallback WHERE condition based on what the user specified
|
||||
let triggeredAtFallback: WhereClauseCondition;
|
||||
if (timeFilter.from && timeFilter.to) {
|
||||
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
|
||||
} else if (timeFilter.from) {
|
||||
triggeredAtFallback = { op: "gte", value: timeFilter.from };
|
||||
} else if (timeFilter.to) {
|
||||
triggeredAtFallback = { op: "lte", value: timeFilter.to };
|
||||
} else {
|
||||
triggeredAtFallback = { op: "gte", value: requestedFromDate! };
|
||||
}
|
||||
|
||||
const maxQueryPeriod = await getLimit(project.organizationId, "queryPeriodDays", 30);
|
||||
const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check if the requested time period exceeds the plan limit
|
||||
const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate;
|
||||
|
||||
// Force tenant isolation and time period limits
|
||||
const enforcedWhereClause = {
|
||||
organization_id: { op: "eq", value: project.organizationId },
|
||||
project_id:
|
||||
scope === "project" || scope === "environment" ? { op: "eq", value: project.id } : undefined,
|
||||
environment_id: scope === "environment" ? { op: "eq", value: environment.id } : undefined,
|
||||
triggered_at: { op: "gte", value: maxQueryPeriodDate },
|
||||
} satisfies Record<string, WhereClauseCondition | undefined>;
|
||||
|
||||
try {
|
||||
const [error, result] = await executeQuery({
|
||||
const [error, result, queryId] = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
schema: z.record(z.any()),
|
||||
@@ -298,6 +316,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
explain,
|
||||
enforcedWhereClause,
|
||||
whereClauseFallback: {
|
||||
triggered_at: triggeredAtFallback,
|
||||
},
|
||||
@@ -323,8 +342,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -336,8 +358,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: result.columns,
|
||||
stats: result.stats,
|
||||
hiddenColumns: result.hiddenColumns ?? null,
|
||||
reachedMaxRows: result.reachedMaxRows,
|
||||
explainOutput: result.explainOutput ?? null,
|
||||
generatedSql: result.generatedSql ?? null,
|
||||
queryId,
|
||||
periodClipped: periodClipped ? maxQueryPeriod : null,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
|
||||
@@ -348,8 +373,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -368,18 +396,45 @@ interface QueryEditorFormHandle {
|
||||
const QueryEditorForm = forwardRef<
|
||||
QueryEditorFormHandle,
|
||||
{
|
||||
defaultPeriod: string;
|
||||
defaultQuery: string;
|
||||
defaultScope: QueryScope;
|
||||
defaultTimeFilter?: { period?: string; from?: string; to?: string };
|
||||
history: QueryHistoryItem[];
|
||||
fetcher: ReturnType<typeof useTypedFetcher<typeof action>>;
|
||||
isAdmin: boolean;
|
||||
onQuerySubmit?: () => void;
|
||||
onHistorySelected?: (item: QueryHistoryItem) => void;
|
||||
}
|
||||
>(function QueryEditorForm({ defaultQuery, defaultScope, defaultTimeFilter, history, fetcher, isAdmin }, ref) {
|
||||
>(function QueryEditorForm(
|
||||
{
|
||||
defaultPeriod,
|
||||
defaultQuery,
|
||||
defaultScope,
|
||||
defaultTimeFilter,
|
||||
history,
|
||||
fetcher,
|
||||
isAdmin,
|
||||
onQuerySubmit,
|
||||
onHistorySelected,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
|
||||
const [query, setQuery] = useState(defaultQuery);
|
||||
const [scope, setScope] = useState<QueryScope>(defaultScope);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const prevFetcherState = useRef(fetcher.state);
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
// Notify parent when query is submitted (for title generation)
|
||||
useEffect(() => {
|
||||
if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") {
|
||||
onQuerySubmit?.();
|
||||
}
|
||||
prevFetcherState.current = fetcher.state;
|
||||
}, [fetcher.state, onQuerySubmit]);
|
||||
|
||||
// Get time filter values - initialize from props (which may come from history)
|
||||
const [period, setPeriod] = useState<string | undefined>(defaultTimeFilter?.period);
|
||||
@@ -406,18 +461,23 @@ const QueryEditorForm = forwardRef<
|
||||
[query]
|
||||
);
|
||||
|
||||
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
|
||||
setQuery(item.query);
|
||||
setScope(item.scope);
|
||||
// Apply time filter from history item
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
setPeriod(item.filterPeriod ?? undefined);
|
||||
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
|
||||
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
|
||||
}, []);
|
||||
const handleHistorySelected = useCallback(
|
||||
(item: QueryHistoryItem) => {
|
||||
setQuery(item.query);
|
||||
setScope(item.scope);
|
||||
// Apply time filter from history item
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
setPeriod(item.filterPeriod ?? undefined);
|
||||
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
|
||||
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
|
||||
// Notify parent about history selection (for title)
|
||||
onHistorySelected?.(item);
|
||||
},
|
||||
[onHistorySelected]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-charcoal-900 pb-2">
|
||||
<div className="flex h-full flex-col gap-2 bg-charcoal-900 pb-2">
|
||||
<TSQLEditor
|
||||
defaultValue={query}
|
||||
onChange={setQuery}
|
||||
@@ -425,10 +485,13 @@ const QueryEditorForm = forwardRef<
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
showClearButton={true}
|
||||
minHeight="200px"
|
||||
className="min-h-[200px]"
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<fetcher.Form ref={formRef} method="post" className="flex items-center justify-between gap-2 px-2">
|
||||
<fetcher.Form
|
||||
ref={formRef}
|
||||
method="post"
|
||||
className="flex items-center justify-between gap-2 px-2"
|
||||
>
|
||||
<input type="hidden" name="query" value={query} />
|
||||
<input type="hidden" name="scope" value={scope} />
|
||||
{/* Pass time filter values to action */}
|
||||
@@ -468,14 +531,16 @@ const QueryEditorForm = forwardRef<
|
||||
</Select>
|
||||
{queryHasTriggeredAt ? (
|
||||
<SimpleTooltip
|
||||
button={<Button variant="tertiary/small" disabled={true} type="button">
|
||||
Set in query
|
||||
</Button>}
|
||||
button={
|
||||
<Button variant="tertiary/small" disabled={true} type="button">
|
||||
Set in query
|
||||
</Button>
|
||||
}
|
||||
content="Your query includes a WHERE clause with triggered_at so this filter is disabled."
|
||||
/>
|
||||
) : (
|
||||
<TimeFilter
|
||||
defaultPeriod={DEFAULT_PERIOD}
|
||||
defaultPeriod={defaultPeriod}
|
||||
labelName="Triggered"
|
||||
hideLabel
|
||||
period={period}
|
||||
@@ -492,6 +557,7 @@ const QueryEditorForm = forwardRef<
|
||||
fetcher.submit(formRef.current);
|
||||
}
|
||||
}}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
@@ -510,22 +576,28 @@ const QueryEditorForm = forwardRef<
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { defaultQuery, history, isAdmin } = useTypedLoaderData<typeof loader>();
|
||||
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useTypedFetcher<typeof action>();
|
||||
const results = fetcher.data;
|
||||
const { replace: replaceSearchParams } = useSearchParams();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Use most recent history item if available, otherwise fall back to defaults
|
||||
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
|
||||
const initialScope: QueryScope = history.length > 0 ? history[0].scope : "environment";
|
||||
const initialTimeFilter = history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
const initialTimeFilter =
|
||||
history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const editorRef = useRef<QueryEditorFormHandle>(null);
|
||||
const [prettyFormatting, setPrettyFormatting] = useState(true);
|
||||
@@ -534,6 +606,53 @@ export default function Page() {
|
||||
const [sidebarTab, setSidebarTab] = useState<string>("ai");
|
||||
const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null);
|
||||
|
||||
// Title generation state
|
||||
const titleFetcher = useFetcher<typeof titleAction>();
|
||||
const isTitleLoading = titleFetcher.state !== "idle";
|
||||
const generatedTitle = titleFetcher.data?.title;
|
||||
const [historyTitle, setHistoryTitle] = useState<string | null>(
|
||||
history.length > 0 ? history[0].title ?? null : null
|
||||
);
|
||||
|
||||
// Effective title: history title takes precedence, then generated
|
||||
const queryTitle = historyTitle ?? generatedTitle ?? null;
|
||||
|
||||
// Track whether we should generate a title for the current results
|
||||
const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false);
|
||||
|
||||
// Trigger title generation when query succeeds (only for new queries, not history)
|
||||
useEffect(() => {
|
||||
if (
|
||||
results?.rows &&
|
||||
!results.error &&
|
||||
results.queryId &&
|
||||
shouldGenerateTitle &&
|
||||
!historyTitle &&
|
||||
titleFetcher.state === "idle"
|
||||
) {
|
||||
const currentQuery = editorRef.current?.getQuery();
|
||||
if (currentQuery) {
|
||||
titleFetcher.submit(
|
||||
{ query: currentQuery, queryId: results.queryId },
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`,
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
setShouldGenerateTitle(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
results,
|
||||
shouldGenerateTitle,
|
||||
historyTitle,
|
||||
titleFetcher,
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
]);
|
||||
|
||||
const handleTryFixError = useCallback((errorMessage: string) => {
|
||||
setSidebarTab("ai");
|
||||
setAiFixRequest((prev) => ({
|
||||
@@ -576,6 +695,18 @@ export default function Page() {
|
||||
setChartConfig(config);
|
||||
}, []);
|
||||
|
||||
// Handle query submission - prepare for title generation
|
||||
const handleQuerySubmit = useCallback(() => {
|
||||
setHistoryTitle(null); // Clear history title when running a new query
|
||||
setShouldGenerateTitle(true); // Enable title generation for new results
|
||||
}, []);
|
||||
|
||||
// Handle history selection - use existing title if available
|
||||
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
|
||||
setHistoryTitle(item.title ?? null);
|
||||
setShouldGenerateTitle(false); // Don't generate title for history items
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -584,23 +715,38 @@ export default function Page() {
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full bg-charcoal-800">
|
||||
<ResizablePanel id="query-main" className="h-full">
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<ResizablePanelGroup orientation="vertical" className="h-full overflow-hidden">
|
||||
{/* Query editor - isolated component to prevent re-renders */}
|
||||
<QueryEditorForm
|
||||
ref={editorRef}
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
fetcher={fetcher}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="query-editor"
|
||||
min="100px"
|
||||
default="300px"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<QueryEditorForm
|
||||
ref={editorRef}
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
fetcher={fetcher}
|
||||
isAdmin={isAdmin}
|
||||
onQuerySubmit={handleQuerySubmit}
|
||||
onHistorySelected={handleHistorySelected}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="query-editor-handle" />
|
||||
{/* Results */}
|
||||
<div className="grid max-h-full grid-rows-[1fr] overflow-hidden border-t border-grid-dimmed bg-charcoal-800">
|
||||
<ResizablePanel
|
||||
id="query-results"
|
||||
min="200px"
|
||||
className="overflow-hidden bg-charcoal-800"
|
||||
>
|
||||
<ClientTabs
|
||||
value={resultsView}
|
||||
onValueChange={(v) => setResultsView(v as "table" | "graph")}
|
||||
className="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden"
|
||||
className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden"
|
||||
>
|
||||
<ClientTabsList
|
||||
variant="underline"
|
||||
@@ -620,12 +766,22 @@ export default function Page() {
|
||||
{results?.rows ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-2 overflow-hidden border-b border-grid-dimmed pl-3">
|
||||
<div className="flex items-center gap-2 overflow-hidden truncate">
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{results?.rows?.length ? `${results.rows.length} Results` : "Results"}
|
||||
</span>
|
||||
{results.reachedMaxRows ? (
|
||||
<SimpleTooltip
|
||||
buttonClassName="text-warning text-xs"
|
||||
button={`${results.rows.length.toLocaleString()} Results`}
|
||||
content={`Results are limited to ${maxRows.toLocaleString()} rows maximum.`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{results.rows.length > 0
|
||||
? `${results.rows.length.toLocaleString()} Results`
|
||||
: "Results"}
|
||||
</span>
|
||||
)}
|
||||
{results?.stats && (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{formatQueryStats(results.stats)}
|
||||
{formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -687,19 +843,32 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : results?.rows && results?.columns ? (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{results.hiddenColumns && results.hiddenColumns.length > 0 && (
|
||||
<Callout variant="warning" className="m-2 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">
|
||||
{results.hiddenColumns.join(", ")}
|
||||
</span>
|
||||
. Specify them explicitly to include them.
|
||||
</Callout>
|
||||
)}
|
||||
<div className="h-full bg-charcoal-900 p-2">
|
||||
<Card className="h-full overflow-hidden p-0">
|
||||
<div
|
||||
className={`grid h-full max-h-full overflow-hidden bg-charcoal-900 ${
|
||||
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
|
||||
? "grid-rows-[auto_1fr]"
|
||||
: "grid-rows-[1fr]"
|
||||
}`}
|
||||
>
|
||||
<QueryResultsCallouts
|
||||
hiddenColumns={results.hiddenColumns}
|
||||
periodClipped={results.periodClipped}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
<div className="overflow-hidden p-2">
|
||||
<Card className="h-full overflow-hidden px-0 pb-0">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TableCellsIcon className="size-5 text-indigo-500" />
|
||||
{isTitleLoading ? (
|
||||
<span className="flex items-center gap-2 text-text-dimmed">
|
||||
<Spinner className="size-3" /> Generating title...
|
||||
</span>
|
||||
) : (
|
||||
queryTitle ?? "Results"
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<TSQLResultsTable
|
||||
rows={results.rows}
|
||||
@@ -718,15 +887,30 @@ export default function Page() {
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="graph"
|
||||
className="m-0 grid min-h-0 grid-rows-[1fr] overflow-hidden"
|
||||
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-charcoal-900 ${
|
||||
results?.rows &&
|
||||
results.rows.length > 0 &&
|
||||
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
|
||||
? "grid-rows-[auto_1fr]"
|
||||
: "grid-rows-[1fr]"
|
||||
}`}
|
||||
>
|
||||
{results?.rows && results?.columns && results.rows.length > 0 ? (
|
||||
<ResultsChart
|
||||
rows={results.rows}
|
||||
columns={results.columns}
|
||||
chartConfig={chartConfig}
|
||||
onChartConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
<>
|
||||
<QueryResultsCallouts
|
||||
hiddenColumns={results.hiddenColumns}
|
||||
periodClipped={results.periodClipped}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
<ResultsChart
|
||||
rows={results.rows}
|
||||
columns={results.columns}
|
||||
chartConfig={chartConfig}
|
||||
onChartConfigChange={handleChartConfigChange}
|
||||
queryTitle={queryTitle}
|
||||
isTitleLoading={isTitleLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Paragraph variant="small" className="p-4 text-text-dimmed">
|
||||
Run a query to visualize results.
|
||||
@@ -734,13 +918,15 @@ export default function Page() {
|
||||
)}
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="query-handle" />
|
||||
<ResizablePanel
|
||||
id="query-help"
|
||||
min="200px"
|
||||
collapsible
|
||||
collapsedSize="20px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
className="w-full"
|
||||
@@ -844,52 +1030,128 @@ function ScopeItem({ scope }: { scope: QueryScope }) {
|
||||
}
|
||||
}
|
||||
|
||||
function QueryResultsCallouts({
|
||||
hiddenColumns,
|
||||
periodClipped,
|
||||
organizationSlug,
|
||||
}: {
|
||||
hiddenColumns: string[] | null | undefined;
|
||||
periodClipped: number | null | undefined;
|
||||
organizationSlug: string;
|
||||
}) {
|
||||
const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped;
|
||||
|
||||
if (!hasCallouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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.
|
||||
</Callout>
|
||||
)}
|
||||
{periodClipped && (
|
||||
<Callout
|
||||
variant="pricing"
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
to={organizationBillingPath({ slug: organizationSlug })}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
className="items-center"
|
||||
>
|
||||
{simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`}
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasQueryResultsCallouts(
|
||||
hiddenColumns: string[] | null | undefined,
|
||||
periodClipped: number | null | undefined
|
||||
): boolean {
|
||||
return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped;
|
||||
}
|
||||
|
||||
function ResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
chartConfig,
|
||||
onChartConfigChange,
|
||||
queryTitle,
|
||||
isTitleLoading,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
chartConfig: ChartConfiguration;
|
||||
onChartConfigChange: (config: ChartConfiguration) => void;
|
||||
queryTitle: string | null;
|
||||
isTitleLoading: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<><ResizablePanelGroup className="h-full overflow-hidden">
|
||||
<ResizablePanel id="chart-results">
|
||||
<div className="h-full bg-charcoal-900 p-2 overflow-hidden">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
|
||||
Chart
|
||||
</div>
|
||||
<Card.Accessory>
|
||||
<Button variant="minimal/small" LeadingIcon={ArrowsPointingOutIcon} onClick={() => setIsOpen(true)} />
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-full flex-1 min-h-0">
|
||||
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} onViewAllLegendItems={() => setIsOpen(true)} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
const titleContent = isTitleLoading ? (
|
||||
<span className="flex items-center gap-2 text-text-dimmed">
|
||||
<Spinner className="size-3" /> Generating title...
|
||||
</span>
|
||||
) : (
|
||||
queryTitle ?? "Chart"
|
||||
);
|
||||
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="chart-split" />
|
||||
<ResizablePanel id="chart-config" min="50px" default="200px">
|
||||
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
return (
|
||||
<>
|
||||
<ResizablePanelGroup className="overflow-hidden">
|
||||
<ResizablePanel id="chart-results">
|
||||
<div className="h-full overflow-hidden bg-charcoal-900 p-2">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
|
||||
{titleContent}
|
||||
</div>
|
||||
<Card.Accessory>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ArrowsPointingOutIcon}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-full min-h-0 flex-1">
|
||||
<QueryResultsChart
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
config={chartConfig}
|
||||
onViewAllLegendItems={() => setIsOpen(true)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="chart-split" />
|
||||
<ResizablePanel id="chart-config" min="50px" default="200px">
|
||||
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent fullscreen>
|
||||
<DialogHeader>
|
||||
Chart
|
||||
</DialogHeader>
|
||||
<div className="h-full min-h-0 flex-1 overflow-hidden w-full pt-4">
|
||||
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} fullLegend={true} />
|
||||
<DialogHeader>{queryTitle ?? "Chart"}</DialogHeader>
|
||||
<div className="h-full min-h-0 w-full flex-1 overflow-hidden pt-4">
|
||||
<QueryResultsChart
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
config={chartConfig}
|
||||
fullLegend={true}
|
||||
legendScrollable={true}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
export function formatQueryStats(stats: {
|
||||
read_rows: string;
|
||||
read_bytes: string;
|
||||
elapsed_ns: string;
|
||||
byte_seconds: string;
|
||||
}): string {
|
||||
const readRows = parseInt(stats.read_rows, 10);
|
||||
const readBytes = parseInt(stats.read_bytes, 10);
|
||||
const elapsedNs = parseInt(stats.elapsed_ns, 10);
|
||||
const byteSeconds = parseFloat(stats.byte_seconds);
|
||||
|
||||
const elapsedMs = elapsedNs / 1_000_000;
|
||||
const formattedTime =
|
||||
elapsedMs < 1000 ? `${elapsedMs.toFixed(1)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`;
|
||||
const formattedBytes = formatBytes(readBytes);
|
||||
|
||||
return `${readRows.toLocaleString()} rows read · ${formattedBytes} · ${formattedTime} · ${formatBytes(
|
||||
byteSeconds
|
||||
)}s`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes < 0) return "-" + formatBytes(-bytes);
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.max(
|
||||
0,
|
||||
Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
);
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
+22
-3
@@ -436,6 +436,24 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldLiveReload({
|
||||
events,
|
||||
maximumLiveReloadingSetting,
|
||||
run,
|
||||
}: {
|
||||
events: TraceEvent[];
|
||||
maximumLiveReloadingSetting: number;
|
||||
run: { completedAt: string | null };
|
||||
}): boolean {
|
||||
// We don't live reload if there are a ton of spans/logs
|
||||
if (events.length > maximumLiveReloadingSetting) return false;
|
||||
|
||||
// If the run was completed a while ago, we don't need to live reload anymore
|
||||
if (run.completedAt && new Date(run.completedAt).getTime() < Date.now() - 30_000) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function TraceView({
|
||||
run,
|
||||
trace,
|
||||
@@ -453,18 +471,19 @@ function TraceView({
|
||||
|
||||
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
|
||||
trace;
|
||||
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
|
||||
|
||||
const changeToSpan = useDebounce((selectedSpan: string) => {
|
||||
replaceSearchParam("span", selectedSpan, { replace: true });
|
||||
}, 250);
|
||||
|
||||
const isLiveReloading = shouldLiveReload({ events, maximumLiveReloadingSetting, run });
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const streamedEvents = useEventSource(
|
||||
v3RunStreamingPath(organization, project, environment, run),
|
||||
{
|
||||
event: "message",
|
||||
disabled: !shouldLiveReload,
|
||||
disabled: !isLiveReloading,
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
@@ -511,7 +530,7 @@ function TraceView({
|
||||
rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined}
|
||||
queuedDuration={queuedDuration}
|
||||
environmentType={run.environment.type}
|
||||
shouldLiveReload={shouldLiveReload}
|
||||
shouldLiveReload={isLiveReloading}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
rootRun={run.rootTaskRun}
|
||||
parentRun={run.parentTaskRun}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
async function hasLogsPageAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
isImpersonating: boolean,
|
||||
organizationSlug: string
|
||||
): Promise<boolean> {
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization?.featureFlags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flags = organization.featureFlags as Record<string, unknown>;
|
||||
const hasLogsPageAccessResult = validateFeatureFlagValue(
|
||||
FEATURE_FLAG.hasLogsPageAccess,
|
||||
flags.hasLogsPageAccess
|
||||
);
|
||||
|
||||
return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const canViewLogsPage = user.admin || user.isImpersonating || await hasLogsPageAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
|
||||
return typedjson({ canViewLogsPage });
|
||||
};
|
||||
+25
-9
@@ -4,13 +4,13 @@ import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
|
||||
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
// Valid log levels for filtering
|
||||
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
|
||||
@@ -19,7 +19,10 @@ function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
const isAdmin = user?.admin || user?.isImpersonating;
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
@@ -32,28 +35,41 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const user = await requireUser(request);
|
||||
const isAdmin = user?.admin || user?.isImpersonating;
|
||||
// Get the user's plan to determine log retention limit
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
// Get search term, cursor, levels, and showDebug from query params
|
||||
// Get filters from query params
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const runId = url.searchParams.get("runId") ?? undefined;
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const levels = parseLevelsFromUrl(url);
|
||||
const showDebug = url.searchParams.get("showDebug") === "true";
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
let from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
let to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
|
||||
if (Number.isNaN(from)) from = undefined;
|
||||
if (Number.isNaN(to)) to = undefined;
|
||||
|
||||
const options = LogsListOptionsSchema.parse({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
runId,
|
||||
search,
|
||||
cursor,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
levels,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
retentionLimitDays,
|
||||
}) as any; // Validated by LogsListOptionsSchema at runtime
|
||||
|
||||
const presenter = new LogsListPresenter($replica, clickhouseClient);
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
|
||||
|
||||
const RequestSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
queryId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
// Parse the request body
|
||||
const [error, data] = await tryCatch(request.json());
|
||||
if (error) {
|
||||
return json({ success: false as const, error: error.message, title: null }, { status: 400 });
|
||||
}
|
||||
const submission = RequestSchema.safeParse(data);
|
||||
|
||||
if (!submission.success) {
|
||||
return json(
|
||||
{ success: false as const, error: "Invalid request data", title: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json(
|
||||
{ success: false as const, error: "Project not found", title: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json(
|
||||
{ success: false as const, error: "Environment not found", title: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return json(
|
||||
{ success: false as const, error: "OpenAI API key is not configured", title: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { query, queryId } = submission.data;
|
||||
|
||||
const service = new AIQueryTitleService(openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini"));
|
||||
|
||||
const result = await service.generateTitle(query);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false as const, error: result.error, title: null }, { status: 500 });
|
||||
}
|
||||
|
||||
// Strip leading/trailing quotes that AI sometimes adds
|
||||
const title = result.title.replace(/^["']|["']$/g, "");
|
||||
|
||||
// If a queryId was provided, update the CustomerQuery record with the title
|
||||
if (queryId) {
|
||||
await prisma.customerQuery.update({
|
||||
where: { id: queryId, organizationId: project.organizationId },
|
||||
data: { title },
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true as const, title, error: null });
|
||||
}
|
||||
+47
-34
@@ -73,6 +73,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -319,6 +320,7 @@ function RunBody({
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
|
||||
const canViewLogsPage = useCanViewLogsPage();
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_minmax(3.25rem,auto)] overflow-hidden bg-background-bright">
|
||||
@@ -1012,44 +1014,55 @@ function RunBody({
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{run.logsDeletedAt === null ? (
|
||||
<div className="flex">
|
||||
canViewLogsPage ? (
|
||||
<div className="flex">
|
||||
<LinkButton
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
variant="secondary/medium"
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
View logs
|
||||
</LinkButton>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
className="rounded-l-none border-l-charcoal-700 px-1.5"
|
||||
>
|
||||
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[140px] p-1" align="end">
|
||||
<PopoverMenuItem
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
title="View logs"
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
title="Download logs"
|
||||
icon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
openInNewTab
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
LeadingIcon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-400"
|
||||
variant="secondary/medium"
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
View logs
|
||||
Download logs
|
||||
</LinkButton>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
className="rounded-l-none border-l-charcoal-700 px-1.5"
|
||||
>
|
||||
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[140px] p-1" align="end">
|
||||
<PopoverMenuItem
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
title="View logs"
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
title="Download logs"
|
||||
icon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
openInNewTab
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TSQLQueryResult,
|
||||
} from "@internal/clickhouse";
|
||||
import type { CustomerQuerySource } from "@trigger.dev/database";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
|
||||
import { type z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
@@ -56,17 +56,14 @@ function getDefaultClickhouseSettings(): ClickHouseSettings {
|
||||
|
||||
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
|
||||
ExecuteTSQLOptions<TOut>,
|
||||
"tableSchema" | "organizationId" | "projectId" | "environmentId" | "fieldMappings"
|
||||
"tableSchema" | "fieldMappings"
|
||||
> & {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
tableSchema: TableSchema[];
|
||||
/** The scope of the query - determines tenant isolation */
|
||||
scope: QueryScope;
|
||||
/** Organization ID (required) */
|
||||
organizationId: string;
|
||||
/** Project ID (required for project/environment scope) */
|
||||
projectId: string;
|
||||
/** Environment ID (required for environment scope) */
|
||||
environmentId: string;
|
||||
/** History options for saving query to billing/audit */
|
||||
history?: {
|
||||
/** Where the query originated from */
|
||||
@@ -89,18 +86,27 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
|
||||
customOrgConcurrencyLimit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended result type that includes the optional queryId when saved to history
|
||||
*/
|
||||
export type ExecuteQueryResult<T> =
|
||||
| [error: Error, result: null, queryId: null]
|
||||
| [error: null, result: T, queryId: string | null];
|
||||
|
||||
/**
|
||||
* Execute a TSQL query against ClickHouse with tenant isolation
|
||||
* Handles building tenant options, field mappings, and optionally saves to history
|
||||
* Returns [error, result, queryId] where queryId is the CustomerQuery ID if saved to history
|
||||
*/
|
||||
export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
options: ExecuteQueryOptions<TOut>
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
): Promise<ExecuteQueryResult<Exclude<TSQLQueryResult<z.output<TOut>>[1], null>>> {
|
||||
const {
|
||||
scope,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
enforcedWhereClause,
|
||||
history,
|
||||
customOrgConcurrencyLimit,
|
||||
whereClauseFallback,
|
||||
@@ -112,39 +118,22 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT;
|
||||
|
||||
// Acquire concurrency slot
|
||||
const acquireResult = await queryConcurrencyLimiter.acquire({
|
||||
key: organizationId,
|
||||
requestId,
|
||||
keyLimit: orgLimit,
|
||||
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
|
||||
});
|
||||
const acquireResult = await queryConcurrencyLimiter.acquire({
|
||||
key: organizationId,
|
||||
requestId,
|
||||
keyLimit: orgLimit,
|
||||
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
|
||||
});
|
||||
|
||||
if (!acquireResult.success) {
|
||||
const errorMessage =
|
||||
acquireResult.reason === "key_limit"
|
||||
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
|
||||
: "We're experiencing a lot of queries at the moment. Please try again later.";
|
||||
return [new QueryError(errorMessage, { query: options.query }), null];
|
||||
}
|
||||
if (!acquireResult.success) {
|
||||
const errorMessage =
|
||||
acquireResult.reason === "key_limit"
|
||||
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
|
||||
: "We're experiencing a lot of queries at the moment. Please try again later.";
|
||||
return [new QueryError(errorMessage, { query: options.query }), null, null];
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tenant IDs based on scope
|
||||
const tenantOptions: {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
} = {
|
||||
organizationId,
|
||||
};
|
||||
|
||||
if (scope === "project" || scope === "environment") {
|
||||
tenantOptions.projectId = projectId;
|
||||
}
|
||||
|
||||
if (scope === "environment") {
|
||||
tenantOptions.environmentId = environmentId;
|
||||
}
|
||||
|
||||
// Build field mappings for project_ref → project_id and environment_id → slug translation
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { organizationId },
|
||||
@@ -163,18 +152,29 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
|
||||
const result = await executeTSQL(clickhouseClient.reader, {
|
||||
...baseOptions,
|
||||
...tenantOptions,
|
||||
enforcedWhereClause,
|
||||
fieldMappings,
|
||||
whereClauseFallback,
|
||||
clickhouseSettings: {
|
||||
...getDefaultClickhouseSettings(),
|
||||
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
|
||||
},
|
||||
querySettings: {
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
...baseOptions.querySettings, // Allow caller overrides if needed
|
||||
},
|
||||
});
|
||||
|
||||
// If query failed, return early with no queryId
|
||||
if (result[0] !== null) {
|
||||
return [result[0], null, null];
|
||||
}
|
||||
|
||||
let queryId: string | null = null;
|
||||
|
||||
// If query succeeded and history options provided, save to history
|
||||
// Skip history for EXPLAIN queries (admin debugging) and when explicitly skipped (e.g., impersonating)
|
||||
if (result[0] === null && history && !history.skip && !baseOptions.explain) {
|
||||
if (history && !history.skip && !baseOptions.explain) {
|
||||
// Check if this query is the same as the last one saved (avoid duplicate history entries)
|
||||
const lastQuery = await prisma.customerQuery.findFirst({
|
||||
where: {
|
||||
@@ -183,7 +183,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
userId: history.userId ?? null,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
|
||||
select: { id: true, query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
|
||||
});
|
||||
|
||||
const timeFilter = history.timeFilter;
|
||||
@@ -195,17 +195,15 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
lastQuery.filterFrom?.getTime() === (timeFilter?.from?.getTime() ?? undefined) &&
|
||||
lastQuery.filterTo?.getTime() === (timeFilter?.to?.getTime() ?? undefined);
|
||||
|
||||
if (!isDuplicate) {
|
||||
const stats = result[1].stats;
|
||||
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
|
||||
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
|
||||
|
||||
await prisma.customerQuery.create({
|
||||
if (isDuplicate && lastQuery) {
|
||||
// Return the existing query's ID for duplicate queries
|
||||
queryId = lastQuery.id;
|
||||
} else {
|
||||
const created = await prisma.customerQuery.create({
|
||||
data: {
|
||||
query: options.query,
|
||||
scope: scopeToEnum[scope],
|
||||
stats: { ...stats },
|
||||
costInCents,
|
||||
stats: { ...result[1].stats },
|
||||
source: history.source,
|
||||
organizationId,
|
||||
projectId: scope === "project" || scope === "environment" ? projectId : null,
|
||||
@@ -216,10 +214,11 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
filterTo: history.timeFilter?.to ?? null,
|
||||
},
|
||||
});
|
||||
queryId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return [null, result[1], queryId];
|
||||
} finally {
|
||||
// Always release the concurrency slot
|
||||
await queryConcurrencyLimiter.release({
|
||||
|
||||
@@ -8,7 +8,7 @@ import parseDuration from "parse-duration";
|
||||
import { z } from "zod";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server";
|
||||
@@ -163,7 +163,7 @@ export class RunsRepository implements IRunsRepository {
|
||||
|
||||
async #getRepository(): Promise<IRunsRepository> {
|
||||
return startActiveSpan("runsRepository.getRepository", async (span) => {
|
||||
const getFlag = makeFlags(this.options.prisma);
|
||||
const getFlag = makeFlag(this.options.prisma);
|
||||
const runsListRepository = await getFlag({
|
||||
key: FEATURE_FLAG.runsListRepository,
|
||||
defaultValue: this.defaultRepository,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createElement, Fragment, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]);
|
||||
export const LogLevelSchema = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]);
|
||||
export type LogLevel = z.infer<typeof LogLevelSchema>;
|
||||
|
||||
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
export const validLogLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR",];
|
||||
|
||||
// Default styles for search highlighting
|
||||
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
|
||||
@@ -71,10 +71,6 @@ export function highlightSearchText(
|
||||
|
||||
// Convert ClickHouse kind to display level
|
||||
export function kindToLevel(kind: string, status: string): LogLevel {
|
||||
if (status === "CANCELLED") {
|
||||
return "CANCELLED";
|
||||
}
|
||||
|
||||
// ERROR can come from either kind or status
|
||||
if (kind === "LOG_ERROR" || status === "ERROR") {
|
||||
return "ERROR";
|
||||
@@ -94,7 +90,7 @@ export function kindToLevel(kind: string, status: string): LogLevel {
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
case "SPAN_EVENT":
|
||||
default:
|
||||
return "TRACE";
|
||||
return "INFO";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,47 +105,7 @@ export function getLevelColor(level: LogLevel): string {
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
}
|
||||
|
||||
// Event kind badge color styles
|
||||
export function getKindColor(kind: string): string {
|
||||
if (kind === "SPAN") {
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
}
|
||||
if (kind === "SPAN_EVENT") {
|
||||
return "text-amber-400 bg-amber-500/10 border-amber-500/20";
|
||||
}
|
||||
if (kind.startsWith("LOG_")) {
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
}
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
}
|
||||
|
||||
// Get human readable kind label
|
||||
export function getKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "SPAN":
|
||||
return "Span";
|
||||
case "SPAN_EVENT":
|
||||
return "Event";
|
||||
case "LOG_DEBUG":
|
||||
case "LOG_INFO":
|
||||
case "LOG_WARN":
|
||||
case "LOG_ERROR":
|
||||
case "LOG_LOG":
|
||||
return "Log";
|
||||
case "DEBUG_EVENT":
|
||||
return "Debug";
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
return "Override";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//From: https://kettanaito.com/blog/debounce-vs-throttle
|
||||
|
||||
/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */
|
||||
export function throttle(
|
||||
func: (...args: any[]) => void,
|
||||
export function throttle<TArgs extends unknown[]>(
|
||||
func: (...args: TArgs) => void,
|
||||
durationMs: number
|
||||
): (...args: any[]) => void {
|
||||
): (...args: TArgs) => void {
|
||||
let isPrimedToFire = false;
|
||||
|
||||
return (...args: any[]) => {
|
||||
return (...args: TArgs) => {
|
||||
if (!isPrimedToFire) {
|
||||
isPrimedToFire = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
|
||||
export async function canAccessQuery(options: {
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
isImpersonating: boolean;
|
||||
organizationSlug: string;
|
||||
}): Promise<boolean> {
|
||||
const { userId, isAdmin, isImpersonating, organizationSlug } = options;
|
||||
|
||||
// 1. If it's on then we have access
|
||||
const globallyEnabled = env.QUERY_FEATURE_ENABLED === "1";
|
||||
if (globallyEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Admins always have access
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Check if org/global feature flag is on
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const flag = makeFlag();
|
||||
const flagResult = await flag({
|
||||
key: FEATURE_FLAG.hasQueryAccess,
|
||||
defaultValue: false,
|
||||
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
|
||||
});
|
||||
if (flagResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Not enabled anywhere
|
||||
return false;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
clickhouseEventRepositoryV2,
|
||||
} from "./clickhouseEventRepositoryInstance.server";
|
||||
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
|
||||
import { prisma } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG, flags } from "../featureFlags.server";
|
||||
import { FEATURE_FLAG, flag } from "../featureFlags.server";
|
||||
import { getTaskEventStore } from "../taskEventStore.server";
|
||||
|
||||
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
|
||||
@@ -24,13 +24,13 @@ export function resolveEventRepositoryForStore(store: string | undefined): IEven
|
||||
return eventRepository;
|
||||
}
|
||||
|
||||
export const EVENT_STORE_TYPES = {
|
||||
POSTGRES: "postgres",
|
||||
CLICKHOUSE: "clickhouse",
|
||||
CLICKHOUSE_V2: "clickhouse_v2",
|
||||
} as const;
|
||||
export const EVENT_STORE_TYPES = {
|
||||
POSTGRES: "postgres",
|
||||
CLICKHOUSE: "clickhouse",
|
||||
CLICKHOUSE_V2: "clickhouse_v2",
|
||||
} as const;
|
||||
|
||||
export type EventStoreType = typeof EVENT_STORE_TYPES[keyof typeof EVENT_STORE_TYPES];
|
||||
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];
|
||||
|
||||
export async function getConfiguredEventRepository(
|
||||
organizationId: string
|
||||
@@ -122,21 +122,21 @@ export async function getV3EventRepository(
|
||||
async function resolveTaskEventRepositoryFlag(
|
||||
featureFlags: Record<string, unknown> | undefined
|
||||
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
|
||||
const flag = await flags({
|
||||
const flagResult = await flag({
|
||||
key: FEATURE_FLAG.taskEventRepository,
|
||||
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
overrides: featureFlags,
|
||||
});
|
||||
|
||||
if (flag === "clickhouse_v2") {
|
||||
if (flagResult === "clickhouse_v2") {
|
||||
return "clickhouse_v2";
|
||||
}
|
||||
|
||||
if (flag === "clickhouse") {
|
||||
if (flagResult === "clickhouse") {
|
||||
return "clickhouse";
|
||||
}
|
||||
|
||||
return flag;
|
||||
return flagResult;
|
||||
}
|
||||
|
||||
export async function recordRunDebugLog(
|
||||
|
||||
@@ -25,14 +25,14 @@ export type FlagsOptions<T extends FeatureFlagKey> = {
|
||||
overrides?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
function flags<T extends FeatureFlagKey>(
|
||||
export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T> & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> }
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
|
||||
function flags<T extends FeatureFlagKey>(
|
||||
function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T>
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined>;
|
||||
async function flags<T extends FeatureFlagKey>(
|
||||
async function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T>
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
|
||||
const value = await _prisma.featureFlag.findUnique({
|
||||
@@ -60,11 +60,11 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
return flags;
|
||||
return flag;
|
||||
}
|
||||
|
||||
export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function setFlags<T extends FeatureFlagKey>(
|
||||
export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function setFlag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T> & { value: z.infer<(typeof FeatureFlagCatalog)[T]> }
|
||||
): Promise<void> {
|
||||
await _prisma.featureFlag.upsert({
|
||||
@@ -82,8 +82,59 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
};
|
||||
}
|
||||
|
||||
export type AllFlagsOptions = {
|
||||
defaultValues?: Partial<FeatureFlagCatalog>;
|
||||
overrides?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function flags(options?: AllFlagsOptions): Promise<Partial<FeatureFlagCatalog>> {
|
||||
const rows = await _prisma.featureFlag.findMany();
|
||||
|
||||
// Build a map of key -> value from database
|
||||
const dbValues = new Map<string, unknown>();
|
||||
for (const row of rows) {
|
||||
dbValues.set(row.key, row.value);
|
||||
}
|
||||
|
||||
const result: Partial<FeatureFlagCatalog> = {};
|
||||
|
||||
// Process each flag in the catalog
|
||||
for (const key of Object.keys(FeatureFlagCatalog) as FeatureFlagKey[]) {
|
||||
const schema = FeatureFlagCatalog[key];
|
||||
|
||||
// Priority: overrides > database > defaultValues
|
||||
if (options?.overrides?.[key] !== undefined) {
|
||||
const parsed = schema.safeParse(options.overrides[key]);
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (dbValues.has(key)) {
|
||||
const parsed = schema.safeParse(dbValues.get(key));
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.defaultValues?.[key] !== undefined) {
|
||||
const parsed = schema.safeParse(options.defaultValues[key]);
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export const flag = makeFlag();
|
||||
export const flags = makeFlags();
|
||||
export const setFlags = makeSetFlags();
|
||||
export const setFlag = makeSetFlag();
|
||||
|
||||
// Create a Zod schema from the existing catalog
|
||||
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
|
||||
@@ -112,7 +163,7 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma
|
||||
return async function setMultipleFlags(
|
||||
flags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>
|
||||
): Promise<{ key: string; value: any }[]> {
|
||||
const setFlag = makeSetFlags(_prisma);
|
||||
const setFlag = makeSetFlag(_prisma);
|
||||
const updatedFlags: { key: string; value: any }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
|
||||
@@ -167,10 +167,14 @@ export const runsSchema: TableSchema = {
|
||||
expression: "if(depth > 0, true, false)",
|
||||
},
|
||||
|
||||
// Useless until we show the user-provided key
|
||||
idempotency_key: {
|
||||
name: "idempotency_key",
|
||||
...column("String", { description: "Idempotency key", example: "user-123-action-456" }),
|
||||
clickhouseName: "idempotency_key_user",
|
||||
...column("String", { description: "Idempotency key (available from 4.3.3)", example: "user-123-action-456" }),
|
||||
},
|
||||
idempotency_key_scope: {
|
||||
name: "idempotency_key_scope",
|
||||
...column("String", { description: "The idempotency key scope determines whether a task should be considered unique within a parent run, a specific attempt, or globally. An empty value means there's no idempotency key set (available from 4.3.3).", example: "run", allowedValues: ["global", "run", "attempt"], }),
|
||||
},
|
||||
region: {
|
||||
name: "region",
|
||||
@@ -325,6 +329,8 @@ export const runsSchema: TableSchema = {
|
||||
// Output & error (JSON columns)
|
||||
// For JSON columns, NULL checks are transformed to check for empty object '{}'
|
||||
// So `error IS NULL` becomes `error = '{}'` and `error IS NOT NULL` becomes `error != '{}'`
|
||||
// textColumn uses the pre-materialized text columns for better performance
|
||||
// dataPrefix handles the internal {"data": ...} wrapper transparently
|
||||
output: {
|
||||
name: "output",
|
||||
...column("JSON", {
|
||||
@@ -332,6 +338,8 @@ export const runsSchema: TableSchema = {
|
||||
example: '{"result": "success"}',
|
||||
}),
|
||||
nullValue: "'{}'", // Transform NULL checks to compare against empty object
|
||||
textColumn: "output_text", // Use output_text for full JSON value queries
|
||||
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
|
||||
},
|
||||
error: {
|
||||
name: "error",
|
||||
@@ -341,6 +349,8 @@ export const runsSchema: TableSchema = {
|
||||
example: '{"message": "Task failed"}',
|
||||
}),
|
||||
nullValue: "'{}'", // Transform NULL checks to compare against empty object
|
||||
textColumn: "error_text", // Use error_text for full JSON value queries
|
||||
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
|
||||
},
|
||||
|
||||
// Tags & versions
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText, type LanguageModelV1 } from "ai";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
/**
|
||||
* Result type for title generation
|
||||
*/
|
||||
export type AIQueryTitleResult =
|
||||
| { success: true; title: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Service for generating concise titles for SQL queries using AI
|
||||
*/
|
||||
export class AIQueryTitleService {
|
||||
constructor(private readonly model: LanguageModelV1 = openai("gpt-4o-mini")) {}
|
||||
|
||||
/**
|
||||
* Generate a concise title for a SQL query
|
||||
*/
|
||||
async generateTitle(query: string): Promise<AIQueryTitleResult> {
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return { success: false, error: "OpenAI API key is not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: this.model,
|
||||
system: `You are a helpful assistant that generates concise titles for SQL queries.
|
||||
|
||||
Your task is to create a short, descriptive title (5-10 words) that summarizes what the query does.
|
||||
|
||||
Guidelines:
|
||||
- Focus on the main purpose/intent of the query
|
||||
- Use plain language, not technical SQL terms
|
||||
- Start with an action verb when appropriate (e.g., "Count", "List", "Show", "Find")
|
||||
- Be specific about what data is being retrieved
|
||||
- Do not include quotes around the title
|
||||
- Do not include punctuation at the end
|
||||
|
||||
Examples:
|
||||
- "Failed runs by hour over 7 days"
|
||||
- "Top 50 most expensive task runs"
|
||||
- "Run counts grouped by status"
|
||||
- "Average execution time by task"
|
||||
- "Recent runs with errors"`,
|
||||
prompt: `Generate a concise title for this SQL query:\n\n${query}`,
|
||||
maxTokens: 50,
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
metadata: {
|
||||
feature: "ai-query-title",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const title = result.text.trim();
|
||||
|
||||
if (!title) {
|
||||
return { success: false, error: "No title generated" };
|
||||
}
|
||||
|
||||
return { success: true, title };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to generate title",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab
|
||||
import { WithRunEngine } from "../baseService.server";
|
||||
import { WorkerGroupTokenService } from "./workerGroupTokenService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG, makeFlags, makeSetFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server";
|
||||
|
||||
export class WorkerGroupService extends WithRunEngine {
|
||||
private readonly defaultNamePrefix = "worker_group";
|
||||
@@ -47,14 +47,14 @@ export class WorkerGroupService extends WithRunEngine {
|
||||
},
|
||||
});
|
||||
|
||||
const getFlag = makeFlags(this._prisma);
|
||||
const getFlag = makeFlag(this._prisma);
|
||||
const defaultWorkerInstanceGroupId = await getFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
});
|
||||
|
||||
// If there's no global default yet we should set it to the new worker group
|
||||
if (!defaultWorkerInstanceGroupId) {
|
||||
const setFlag = makeSetFlags(this._prisma);
|
||||
const setFlag = makeSetFlag(this._prisma);
|
||||
await setFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
value: workerGroup.id,
|
||||
@@ -166,7 +166,7 @@ export class WorkerGroupService extends WithRunEngine {
|
||||
}
|
||||
|
||||
async getGlobalDefaultWorkerGroup() {
|
||||
const flags = makeFlags(this._prisma);
|
||||
const flags = makeFlag(this._prisma);
|
||||
|
||||
const defaultWorkerInstanceGroupId = await flags({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@trigger.dev/otlp-importer": "workspace:*",
|
||||
"@trigger.dev/platform": "1.0.21",
|
||||
"@trigger.dev/platform": "1.0.22",
|
||||
"@trigger.dev/redis-worker": "workspace:*",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"@types/pg": "8.6.6",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"exclude": ["./cypress", "./cypress.config.ts"],
|
||||
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"types": ["vitest/globals", "node"],
|
||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Overview"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Tools and resources for building Trigger.dev projects with AI coding assistants."
|
||||
---
|
||||
|
||||
We provide tools to help you build Trigger.dev projects with AI coding assistants. We recommend using them for the best developer experience.
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="MCP Server" icon="sparkles" href="/mcp-introduction">
|
||||
Give your AI assistant direct access to Trigger.dev tools - search docs, trigger tasks, deploy projects, and monitor runs.
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp
|
||||
```
|
||||
</Card>
|
||||
<Card title="Skills" icon="wand-magic-sparkles" href="/skills">
|
||||
Portable instruction sets that teach any AI coding assistant Trigger.dev best practices for writing tasks, configs, and more.
|
||||
|
||||
```bash
|
||||
npx skills add triggerdotdev/skills
|
||||
```
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -91,6 +91,32 @@ The extension sets the following environment variables during the build:
|
||||
- `PLAYWRIGHT_SKIP_BROWSER_VALIDATION`: Set to `1` to skip browser validation at runtime
|
||||
- `DISPLAY`: Set to `:99` if `headless: false` (for Xvfb)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser download failures
|
||||
|
||||
If you encounter errors during the build process related to browser downloads (e.g., "failed to solve: process did not complete successfully: exit code: 9"), this is a known issue with certain Playwright versions.
|
||||
|
||||
**Workaround:** Revert Playwright to version `1.40.0` in your project dependencies. You can specify this version explicitly in your config:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
import { playwright } from "@trigger.dev/build/extensions/playwright";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project ref>",
|
||||
build: {
|
||||
extensions: [
|
||||
playwright({
|
||||
version: "1.40.0",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For more details, see [GitHub issue #2440](https://github.com/triggerdotdev/trigger.dev/issues/2440#issuecomment-3815104376).
|
||||
|
||||
## Managing browser instances
|
||||
|
||||
To prevent issues with waits and resumes, you can use middleware and locals to manage the browser instance. This will ensure the browser is available for the whole run, and is properly cleaned up on waits, resumes, and after the run completes.
|
||||
|
||||
@@ -66,6 +66,18 @@ You can edit an environment variable's values. You cannot edit the key name, you
|
||||
|
||||
</Steps>
|
||||
|
||||
## Local development
|
||||
|
||||
When running `npx trigger.dev dev`, the CLI automatically loads environment variables from these files in order (later files override any duplicate keys from earlier ones):
|
||||
|
||||
- `.env`
|
||||
- `.env.development`
|
||||
- `.env.local`
|
||||
- `.env.development.local`
|
||||
- `dev.vars`
|
||||
|
||||
These variables are available to your tasks via `process.env`. You don't need to use the `--env-file` flag for this automatic loading.
|
||||
|
||||
## In your code
|
||||
|
||||
You can use our SDK to get and manipulate environment variables. You can also easily sync environment variables from another service into Trigger.dev.
|
||||
@@ -360,4 +372,55 @@ This will read your .env.production file using dotenvx and sync the variables to
|
||||
|
||||
- Trigger.dev does not automatically detect .env.production or dotenvx files
|
||||
- You can paste them manually into the dashboard
|
||||
- Or sync them automatically using a build extension
|
||||
- Or sync them automatically using a build extension
|
||||
|
||||
## Multi-tenant applications
|
||||
|
||||
If you're building a multi-tenant application where each tenant needs different environment variables (like tenant-specific API keys or database credentials), you don't need a separate project for each tenant. Instead, use a single project and load tenant-specific secrets at runtime.
|
||||
|
||||
<Note>
|
||||
This is different from [syncing environment variables at deploy time](#sync-env-vars-from-another-service).
|
||||
Here, secrets are loaded dynamically during task execution, not synced to Trigger.dev's environment variables.
|
||||
</Note>
|
||||
|
||||
### Recommended approach
|
||||
|
||||
Use a secrets service (Infisical, AWS Secrets Manager, HashiCorp Vault, etc.) to store tenant-specific secrets, then retrieve them at the start of each task run based on the tenant identifier in your payload or context.
|
||||
|
||||
**Important:** Never pass secrets in the task payload, as payloads are logged and visible in the dashboard.
|
||||
|
||||
### Example implementation
|
||||
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||
|
||||
export const processTenantData = task({
|
||||
id: "process-tenant-data",
|
||||
run: async (payload: { tenantId: string; data: unknown }) => {
|
||||
// Retrieve tenant-specific secret at runtime
|
||||
const client = new SecretsManagerClient({ region: "us-east-1" });
|
||||
const response = await client.send(
|
||||
new GetSecretValueCommand({
|
||||
SecretId: `tenants/${payload.tenantId}/supabase-key`,
|
||||
})
|
||||
);
|
||||
|
||||
const supabaseKey = JSON.parse(response.SecretString!).SUPABASE_SERVICE_KEY;
|
||||
|
||||
// Your task logic using the tenant-specific secret
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can use any secrets service - see the [sync env vars section](#sync-env-vars-from-another-service) for an example with Infisical.
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Single codebase** - Deploy once, works for all tenants
|
||||
- **Secure** - Secrets never appear in payloads or logs
|
||||
- **Scalable** - No project limit constraints
|
||||
- **Flexible** - Easy to add new tenants without redeploying
|
||||
|
||||
This approach allows you to support unlimited tenants with a single Trigger.dev project, avoiding the [project limit](/limits#projects) while maintaining security and separation of tenant data.
|
||||
+24
-4
@@ -43,6 +43,17 @@
|
||||
"apikeys"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Building with AI",
|
||||
"pages": [
|
||||
"building-with-ai",
|
||||
{
|
||||
"group": "MCP Server",
|
||||
"pages": ["mcp-introduction", "mcp-tools", "mcp-agent-rules"]
|
||||
},
|
||||
"skills"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Writing tasks",
|
||||
"pages": [
|
||||
@@ -166,10 +177,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "MCP Server",
|
||||
"pages": ["mcp-introduction", "mcp-tools", "mcp-agent-rules"]
|
||||
},
|
||||
{
|
||||
"group": "Using the Dashboard",
|
||||
"pages": ["run-tests", "troubleshooting-alerts", "replaying", "bulk-actions"]
|
||||
@@ -269,6 +276,14 @@
|
||||
"management/envvars/update",
|
||||
"management/envvars/delete"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Deployments API",
|
||||
"pages": [
|
||||
"management/deployments/retrieve",
|
||||
"management/deployments/get-latest",
|
||||
"management/deployments/promote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -439,6 +454,11 @@
|
||||
"display": "simple"
|
||||
}
|
||||
},
|
||||
"styling": {
|
||||
"codeblocks": {
|
||||
"theme": "css-variables"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"default": "dark",
|
||||
"strict": true
|
||||
|
||||
@@ -428,18 +428,22 @@ export const parentTask = task({
|
||||
});
|
||||
```
|
||||
|
||||
When resetting from outside a task (e.g., from your backend code), you must provide the `parentRunId`:
|
||||
When resetting from outside a task, you must provide the `parentRunId` if the key was created within a task context:
|
||||
|
||||
```ts
|
||||
import { idempotencyKeys } from "@trigger.dev/sdk";
|
||||
|
||||
// From your backend code - you need to know the parent run ID
|
||||
// If the key was created within a task, you need the parent run ID
|
||||
await idempotencyKeys.reset("my-task", "my-key", {
|
||||
scope: "run",
|
||||
parentRunId: "run_abc123"
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you triggered the task from backend code, all scopes behave as global (see [Triggering from backend code](#triggering-from-backend-code)). Use `scope: "global"` when resetting.
|
||||
</Note>
|
||||
|
||||
### Resetting attempt-scoped keys
|
||||
|
||||
Keys created with `"attempt"` scope include both the parent run ID and attempt number. When resetting from outside a task, you must provide both:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -16,8 +16,8 @@ mode: "center"
|
||||
>
|
||||
Browse our wide range of guides, frameworks and example projects
|
||||
</Card>
|
||||
<Card title="MCP Server" img="/images/intro-mcp.jpg" href="/mcp-introduction">
|
||||
Learn how to install and configure the Trigger.dev MCP Server
|
||||
<Card title="Building with AI" img="/images/intro-ai.jpg" href="/building-with-ai">
|
||||
Learn how to build Trigger.dev projects using AI coding assistants
|
||||
</Card>
|
||||
<Card title="Video walkthrough" img="/images/intro-video.jpg" href="/video-walkthrough">
|
||||
Watch an end-to-end demo of Trigger.dev in 10 minutes
|
||||
|
||||
@@ -55,6 +55,14 @@ If you add them [dynamically using code](/management/schedules/create) make sure
|
||||
|
||||
If you're creating schedules for your user you will definitely need to request more schedules from us.
|
||||
|
||||
## Projects
|
||||
|
||||
| Pricing tier | Limit |
|
||||
| :----------- | :----------------- |
|
||||
| All tiers | 10 per organization |
|
||||
|
||||
Each project receives its own concurrency allocation. If you need to support multiple tenants with the same codebase but different environment variables, see the [Multi-tenant applications](/deploy-environment-variables#multi-tenant-applications) section for a recommended workaround.
|
||||
|
||||
## Preview branches
|
||||
|
||||
| Pricing tier | Limit |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get latest deployment"
|
||||
openapi: "v3-openapi GET /api/v1/deployments/latest"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Promote deployment"
|
||||
openapi: "v3-openapi POST /api/v1/deployments/{version}/promote"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get deployment"
|
||||
openapi: "v3-openapi GET /api/v1/deployments/{deploymentId}"
|
||||
---
|
||||
@@ -2,7 +2,6 @@
|
||||
title: "Agent rules"
|
||||
sidebarTitle: "Agent rules"
|
||||
description: "Learn how to use the Trigger.dev agent rules with the MCP server"
|
||||
tag: "new"
|
||||
---
|
||||
|
||||
## What are Trigger.dev agent rules?
|
||||
|
||||
+288
-111
@@ -2,7 +2,6 @@
|
||||
title: "MCP Introduction"
|
||||
sidebarTitle: "Introduction"
|
||||
description: "Learn how to install and configure the Trigger.dev MCP Server"
|
||||
tag: "new"
|
||||
---
|
||||
|
||||
## What is the Trigger.dev MCP Server?
|
||||
@@ -18,44 +17,306 @@ The Trigger.dev MCP (Model Context Protocol) Server enables AI assistants to int
|
||||
|
||||
## Installation
|
||||
|
||||
### Automatic Installation (Recommended)
|
||||
|
||||
The easiest way to install the Trigger.dev MCP Server is using the interactive installation wizard:
|
||||
The quickest way to get set up is the interactive installer:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp
|
||||
```
|
||||
|
||||
This command will guide you through:
|
||||
It will detect your installed clients and configure them automatically. You can also copy-paste the config for your client below.
|
||||
|
||||
1. Selecting which MCP clients to configure
|
||||
2. Choosing installation scope (user, project, or local)
|
||||
3. Automatically configuring the selected clients
|
||||
## Client Configuration
|
||||
|
||||
## Command Line Options
|
||||
Each client has a slightly different config format. Copy the snippet for your client into the appropriate file.
|
||||
|
||||
The `install-mcp` command supports the following options:
|
||||
<Tabs>
|
||||
<Tab title="Claude Code">
|
||||
Install using the command line:
|
||||
|
||||
### Core Options
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client claude-code
|
||||
```
|
||||
|
||||
- `-p, --project-ref <project ref>` - Scope the MCP server to a specific Trigger.dev project by providing its project ref
|
||||
- `-t, --tag <package tag>` - The version of the trigger.dev CLI package to use for the MCP server (default: latest or v4-beta)
|
||||
- `--dev-only` - Restrict the MCP server to the dev environment only
|
||||
- `--yolo` - Install the MCP server into all supported clients automatically
|
||||
- `--scope <scope>` - Choose the scope of the MCP server: `user`, `project`, or `local`
|
||||
- `--client <clients...>` - Choose specific client(s) to install into
|
||||
Or add this configuration to `~/.claude.json` (user) or `.mcp.json` (project):
|
||||
|
||||
### Configuration Options
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `--log-file <log file>` - Configure the MCP server to write logs to a file
|
||||
- `-a, --api-url <value>` - Configure a custom Trigger.dev API URL
|
||||
- `-l, --log-level <level>` - Set CLI log level (debug, info, log, warn, error, none)
|
||||
[View Claude Code MCP docs ↗](https://code.claude.com/docs/en/mcp)
|
||||
</Tab>
|
||||
<Tab title="Cursor">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client cursor
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.cursor/mcp.json` (user) or `.cursor/mcp.json` (project):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Cursor MCP docs ↗](https://cursor.com/docs/context/mcp)
|
||||
</Tab>
|
||||
<Tab title="Windsurf">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client windsurf
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Windsurf MCP docs ↗](https://docs.windsurf.com/windsurf/cascade/mcp)
|
||||
</Tab>
|
||||
<Tab title="VS Code">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client vscode
|
||||
```
|
||||
|
||||
Or add this configuration to `.vscode/mcp.json` (project) or `~/Library/Application Support/Code/User/mcp.json` (user, macOS):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Note>VS Code uses `servers` instead of `mcpServers`.</Note>
|
||||
|
||||
[View VS Code MCP docs ↗](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
|
||||
</Tab>
|
||||
<Tab title="Zed">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client zed
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"context_servers": {
|
||||
"trigger": {
|
||||
"source": "custom",
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Zed context servers docs ↗](https://zed.dev/docs/ai/mcp)
|
||||
</Tab>
|
||||
<Tab title="Cline">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client cline
|
||||
```
|
||||
|
||||
Or add this configuration to `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Cline MCP docs ↗](https://docs.cline.bot/mcp/configuring-mcp-servers)
|
||||
</Tab>
|
||||
<Tab title="Gemini CLI">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client gemini-cli
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.gemini/settings.json` (user) or `.gemini/settings.json` (project):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="AMP">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client amp
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.config/amp/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"amp.mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Sourcegraph AMP MCP docs ↗](https://ampcode.com/manual#mcp)
|
||||
</Tab>
|
||||
<Tab title="Codex CLI">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client openai-codex
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.trigger]
|
||||
command = "npx"
|
||||
args = ["trigger.dev@latest", "mcp"]
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Crush">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client crush
|
||||
```
|
||||
|
||||
Or add this configuration to `.crush.json` (project), `crush.json`, or `~/.config/crush/crush.json` (user). Files are loaded in priority order: `.crush.json` → `crush.json` → `$HOME/.config/crush/crush.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"trigger": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View Charm MCP docs ↗](https://github.com/charmbracelet/crush)
|
||||
</Tab>
|
||||
<Tab title="opencode">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client opencode
|
||||
```
|
||||
|
||||
Or add this configuration to `~/.config/opencode/opencode.json` (user) or `./opencode.json` (project):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"trigger": {
|
||||
"type": "local",
|
||||
"command": ["npx", "trigger.dev@latest", "mcp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[View opencode MCP docs ↗](https://opencode.ai/docs/mcp-servers/)
|
||||
</Tab>
|
||||
<Tab title="Ruler">
|
||||
Install using the command line:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --client ruler
|
||||
```
|
||||
|
||||
Or add this configuration to `.ruler/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
After adding the config, restart your client. You should see a server named **trigger** connect automatically.
|
||||
|
||||
## Authentication
|
||||
|
||||
You can use the MCP server without authentication with the `search_docs` tool, but for any other tool call you will need to authenticate the MCP server via the same method as the [Trigger.dev CLI](/cli-login-commands).The first time you attempt to use a tool that requires authentication, you will be prompted to authenticate the MCP server via the MCP client.
|
||||
The `search_docs` tool works without authentication. All other tools require you to be logged in via the [Trigger.dev CLI](/cli-login-commands). The first time you use an authenticated tool, your MCP client will prompt you to log in.
|
||||
|
||||
### Examples
|
||||
<Accordion title="CLI Options">
|
||||
|
||||
The `install-mcp` command supports these options:
|
||||
|
||||
**Core Options**
|
||||
|
||||
- `-p, --project-ref <project ref>` — Scope the MCP server to a specific project
|
||||
- `-t, --tag <package tag>` — CLI package version to use (default: latest)
|
||||
- `--dev-only` — Restrict to the dev environment only
|
||||
- `--yolo` — Install into all supported clients automatically
|
||||
- `--scope <scope>` — `user`, `project`, or `local`
|
||||
- `--client <clients...>` — Install into specific client(s)
|
||||
|
||||
**Configuration Options**
|
||||
|
||||
- `--log-file <log file>` — Write logs to a file
|
||||
- `-a, --api-url <value>` — Custom Trigger.dev API URL
|
||||
- `-l, --log-level <level>` — Log level (debug, info, log, warn, error, none)
|
||||
|
||||
**Examples**
|
||||
|
||||
Install for all supported clients:
|
||||
|
||||
@@ -69,105 +330,21 @@ Install for specific clients:
|
||||
npx trigger.dev@latest install-mcp --client claude-code cursor --scope user
|
||||
```
|
||||
|
||||
Install with development environment restriction:
|
||||
Restrict to dev environment for a specific project:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest install-mcp --dev-only --project-ref proj_abc123
|
||||
```
|
||||
|
||||
## Supported MCP Clients
|
||||
|
||||
The Trigger.dev MCP Server supports the following clients:
|
||||
|
||||
| Client | Scope Options | Configuration File | Documentation |
|
||||
| -------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Claude Code** | user, project, local | `~/.claude.json` or `./.mcp.json` (project/local scope) | [Claude Code MCP Docs](https://docs.anthropic.com/en/docs/claude-code/mcp) |
|
||||
| **Cursor** | user, project | `~/.cursor/mcp.json` (user) or `./.cursor/mcp.json` (project) | [Cursor MCP Docs](https://docs.cursor.com/features/mcp) |
|
||||
| **VSCode** | user, project | `~/Library/Application Support/Code/User/mcp.json` (user) or `./.vscode/mcp.json` (project) | [VSCode MCP Docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) |
|
||||
| **Zed** | user | `~/.config/zed/settings.json` | [Zed Context Servers Docs](https://zed.dev/docs/context-servers) |
|
||||
| **Windsurf** | user | `~/.codeium/windsurf/mcp_config.json` | [Windsurf MCP Docs](https://docs.codeium.com/windsurf/mcp) |
|
||||
| **Gemini CLI** | user, project | `~/.gemini/settings.json` (user) or `./.gemini/settings.json` (project) | [Gemini CLI MCP Tutorial](https://medium.com/@joe.njenga/gemini-cli-mcp-tutorial-setup-commands-practical-use-step-by-step-example-b57f55db5f4a) |
|
||||
| **Charm Crush** | user, project, local | `~/.config/crush/crush.json` (user), `./crush.json` (project), or `./.crush.json` (local) | [Charm MCP Docs](https://github.com/charmbracelet/mcp) |
|
||||
| **Cline** | user | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | [Cline MCP Docs](https://github.com/saoudrizwan/claude-dev#mcp) |
|
||||
| **OpenAI Codex CLI** | user | `~/.codex/config.toml` | See OpenAI Codex CLI documentation for MCP configuration |
|
||||
| **Sourcegraph AMP** | user | `~/.config/amp/settings.json` | [Sourcegraph AMP MCP Docs](https://docs.sourcegraph.com/amp/mcp) |
|
||||
| **opencode** | user, project | `~/.config/opencode/opencode.json` (user) or `./opencode.json` (project) | [opencode MCP Docs](https://opencode.ai/docs/mcp-servers/) |
|
||||
|
||||
## Manual Configuration
|
||||
|
||||
If your client isn't directly supported by the installer, you can configure it manually. The MCP server uses the following configuration:
|
||||
|
||||
**Server Name:** `trigger`
|
||||
|
||||
**Command:** `npx`
|
||||
|
||||
**Arguments:** `["trigger.dev@latest", "mcp"]`
|
||||
|
||||
### Example JSON Configuration
|
||||
To add these options to a manual config, append them to the `args` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
"args": ["trigger.dev@latest", "mcp", "--dev-only", "--project-ref", "proj_abc123"]
|
||||
}
|
||||
```
|
||||
|
||||
### Example TOML Configuration (for Codex CLI)
|
||||
|
||||
```toml
|
||||
[mcp_servers.trigger]
|
||||
command = "npx"
|
||||
args = ["trigger.dev@latest", "mcp"]
|
||||
```
|
||||
|
||||
### Additional Options
|
||||
|
||||
You can add these optional arguments to customize the server behavior:
|
||||
|
||||
- `--log-file <path>` - Log to a specific file
|
||||
- `--api-url <url>` - Use a custom Trigger.dev API URL
|
||||
- `--dev-only` - Restrict to dev environment only
|
||||
- `--project-ref <ref>` - Scope to a specific project
|
||||
|
||||
## Environment-Specific Configuration
|
||||
|
||||
### Development Only
|
||||
|
||||
To restrict the MCP server to only work with the development environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp", "--dev-only"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Project-Scoped
|
||||
|
||||
To scope the server to a specific project:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trigger": {
|
||||
"command": "npx",
|
||||
"args": ["trigger.dev@latest", "mcp", "--project-ref", "proj_your_project_ref"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, restart your MCP client and look for a server named "trigger". The server should connect automatically and provide access to all Trigger.dev tools.
|
||||
</Accordion>
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
+63
-457
@@ -1,527 +1,133 @@
|
||||
---
|
||||
title: "MCP Tools"
|
||||
sidebarTitle: "Tools"
|
||||
description: "Learn about the tools available in the Trigger.dev MCP Server"
|
||||
tag: "new"
|
||||
description: "Learn about how to use the tools available in the Trigger.dev MCP Server"
|
||||
---
|
||||
|
||||
The Trigger.dev MCP Server provides a comprehensive set of tools that enable AI assistants to interact with your Trigger.dev projects. These tools cover everything from project management to task execution and monitoring.
|
||||
|
||||
## Documentation and Search Tools
|
||||
|
||||
### search_docs
|
||||
|
||||
Search across the Trigger.dev documentation to find relevant information, code examples, API references, and guides.
|
||||
Search the Trigger.dev documentation for guides, examples, and API references.
|
||||
|
||||
<ParamField query="query" type="string" required>
|
||||
The search query to find information in the Trigger.dev documentation
|
||||
</ParamField>
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
- "How do I create a scheduled task?"
|
||||
- "webhook examples"
|
||||
- "deployment configuration"
|
||||
- "error handling patterns"
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "search_docs",
|
||||
"arguments": {
|
||||
"query": "webhook examples"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"How do I create a scheduled task?"_
|
||||
- _"Show me webhook examples"_
|
||||
- _"What are the deployment options?"_
|
||||
|
||||
## Project Management Tools
|
||||
|
||||
### list_projects
|
||||
|
||||
List all projects in your Trigger.dev account.
|
||||
|
||||
**No parameters required**
|
||||
|
||||
<ResponseField name="projects" type="array">
|
||||
Array of project objects containing project details, IDs, and metadata
|
||||
</ResponseField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Response
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"id": "proj_abc123",
|
||||
"name": "My App",
|
||||
"slug": "my-app",
|
||||
"organizationId": "org_xyz789"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### list_orgs
|
||||
|
||||
List all organizations you have access to.
|
||||
|
||||
**No parameters required**
|
||||
**Example usage:**
|
||||
- _"What organizations do I have?"_
|
||||
- _"Show me my orgs"_
|
||||
|
||||
<ResponseField name="orgs" type="array">
|
||||
Array of organization objects containing organization details and metadata
|
||||
</ResponseField>
|
||||
### list_projects
|
||||
|
||||
List all projects in your Trigger.dev account.
|
||||
|
||||
**Example usage:**
|
||||
- _"What projects do I have?"_
|
||||
- _"List my Trigger.dev projects"_
|
||||
|
||||
### create_project_in_org
|
||||
|
||||
Create a new project in an organization.
|
||||
|
||||
<ParamField query="orgParam" type="string" required>
|
||||
The organization to create the project in, can either be the organization slug or the ID. Use the
|
||||
`list_orgs` tool to get a list of organizations and ask the user to select one.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="name" type="string" required>
|
||||
The name of the project to create
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "create_project_in_org",
|
||||
"arguments": {
|
||||
"orgParam": "my-org",
|
||||
"name": "New Project"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"Create a new project called 'my-app'"_
|
||||
- _"Set up a new Trigger.dev project"_
|
||||
|
||||
### initialize_project
|
||||
|
||||
Initialize Trigger.dev in your project with automatic setup and configuration.
|
||||
|
||||
<ParamField query="orgParam" type="string" required>
|
||||
The organization to create the project in, can either be the organization slug or the ID. Use the
|
||||
`list_orgs` tool to get a list of organizations and ask the user to select one.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="projectName" type="string" required>
|
||||
The name of the project to create. If projectRef is not provided, we will use this name to create
|
||||
a new project in the organization you select.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="cwd" type="string" required>
|
||||
The current working directory of the project
|
||||
</ParamField>
|
||||
**Example usage:**
|
||||
- _"Set up Trigger.dev in this project"_
|
||||
- _"Add Trigger.dev to my app"_
|
||||
|
||||
## Task Management Tools
|
||||
|
||||
### get_tasks
|
||||
### get_current_worker
|
||||
|
||||
Get all tasks in a project.
|
||||
Get the current worker for a project, including the worker version, SDK version, and registered tasks with their payload schemas.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup). If not provided, we will try to find the config file in the
|
||||
current working directory.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="environment" type="string" default="dev">
|
||||
The environment to get tasks for. Options: `dev`, `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to get tasks for, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "get_tasks",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123",
|
||||
"environment": "dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"What tasks are available?"_
|
||||
- _"Show me the tasks in dev"_
|
||||
|
||||
### trigger_task
|
||||
|
||||
Trigger a task to run.
|
||||
Trigger a task to run with a specific payload. You can add a delay, set tags, configure retries, choose a machine size, set a TTL, or use an idempotency key.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="environment" type="string" default="dev">
|
||||
The environment to trigger the task in. Options: `dev`, `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to trigger the task in, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="taskId" type="string" required>
|
||||
The ID/slug of the task to trigger. Use the `get_tasks` tool to get a list of tasks and ask the
|
||||
user to select one if it's not clear which one to use.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="payload" type="string" required>
|
||||
The payload to trigger the task with, must be a valid JSON string
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="options" type="object" optional>
|
||||
Additional options for the task run
|
||||
<Expandable title="options properties">
|
||||
<ParamField query="queue.name" type="string" optional>
|
||||
The name of the queue to trigger the task in, by default will use the queue configured in the
|
||||
task
|
||||
</ParamField>
|
||||
<ParamField query="delay" type="string | datetime" optional>
|
||||
The delay before the task run is executed
|
||||
</ParamField>
|
||||
<ParamField query="idempotencyKey" type="string" optional>
|
||||
The idempotency key to use for the task run
|
||||
</ParamField>
|
||||
<ParamField query="machine" type="string" optional>
|
||||
The machine preset to use for the task run. Options: `micro`, `small-1x`, `small-2x`,
|
||||
`medium-1x`, `medium-2x`, `large-1x`, `large-2x`
|
||||
</ParamField>
|
||||
<ParamField query="maxAttempts" type="integer" optional>
|
||||
The maximum number of attempts to retry the task run
|
||||
</ParamField>
|
||||
<ParamField query="maxDuration" type="number" optional>
|
||||
The maximum duration in seconds of the task run
|
||||
</ParamField>
|
||||
<ParamField query="tags" type="array" optional>
|
||||
Tags to add to the task run. Must be less than 128 characters and cannot have more than 5
|
||||
</ParamField>
|
||||
<ParamField query="ttl" type="string | integer" default="10m">
|
||||
The time to live of the task run. If the run doesn't start executing within this time, it will
|
||||
be automatically cancelled.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "trigger_task",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123",
|
||||
"taskId": "email-notification",
|
||||
"payload": "{\"email\": \"user@example.com\", \"subject\": \"Hello World\"}",
|
||||
"options": {
|
||||
"tags": ["urgent"],
|
||||
"maxAttempts": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"Run the email-notification task"_
|
||||
- _"Trigger my-task with userId 123"_
|
||||
- _"Execute the sync task in production"_
|
||||
|
||||
## Run Monitoring Tools
|
||||
|
||||
### get_run_details
|
||||
|
||||
Get the details of a specific task run.
|
||||
Get detailed information about a specific task run, including logs and status. Enable debug mode to get the full trace with all logs and spans.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="environment" type="string" default="dev">
|
||||
The environment to get the run details from. Options: `dev`, `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to get the run details from, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="runId" type="string" required>
|
||||
The ID of the run to get the details of, starts with `run_`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="debugMode" type="boolean" optional>
|
||||
Enable debug mode to get more detailed information about the run, including the entire trace (all logs and spans for the run and any child run). Set this to true if prompted to debug a run.
|
||||
</ParamField>
|
||||
|
||||
### cancel_run
|
||||
|
||||
Cancel a running task.
|
||||
|
||||
<ParamField query="runId" type="string" required>
|
||||
The ID of the run to cancel, starts with `run_`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="environment" type="string" default="dev">
|
||||
The environment to cancel the run in. Options: `dev`, `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to cancel the run in, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "cancel_run",
|
||||
"arguments": {
|
||||
"runId": "run_abc123",
|
||||
"projectRef": "proj_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"Show me details for run run_abc123"_
|
||||
- _"Why did this run fail?"_
|
||||
|
||||
### list_runs
|
||||
|
||||
List all runs for a project with comprehensive filtering options.
|
||||
List runs for a project. Filter by status, task, tags, version, machine size, or time period.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
**Example usage:**
|
||||
- _"Show me recent runs"_
|
||||
- _"List failed runs from the last 7 days"_
|
||||
- _"What runs are currently executing?"_
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
### wait_for_run_to_complete
|
||||
|
||||
<ParamField query="environment" type="string" default="dev">
|
||||
The environment to list runs from. Options: `dev`, `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
Wait for a specific run to finish and return the result.
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to list runs from, only used for preview environments
|
||||
</ParamField>
|
||||
**Example usage:**
|
||||
- _"Wait for run run_abc123 to complete"_
|
||||
|
||||
<ParamField query="cursor" type="string" optional>
|
||||
The cursor to use for pagination, starts with `run_`
|
||||
</ParamField>
|
||||
### cancel_run
|
||||
|
||||
<ParamField query="limit" type="integer" optional>
|
||||
The number of runs to list in a single page. Up to 100
|
||||
</ParamField>
|
||||
Cancel a running or queued run.
|
||||
|
||||
<ParamField query="status" type="string" optional>
|
||||
Filter for runs with this run status. Options: `PENDING_VERSION`, `QUEUED`, `DEQUEUED`,
|
||||
`EXECUTING`, `WAITING`, `COMPLETED`, `CANCELED`, `FAILED`, `CRASHED`, `SYSTEM_FAILURE`, `DELAYED`,
|
||||
`EXPIRED`, `TIMED_OUT`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="taskIdentifier" type="string" optional>
|
||||
Filter for runs that match this task identifier
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="version" type="string" optional>
|
||||
Filter for runs that match this version, e.g. `20250808.3`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="tag" type="string" optional>
|
||||
Filter for runs that include this tag
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="from" type="string" optional>
|
||||
Filter for runs created after this ISO 8601 timestamp
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="to" type="string" optional>
|
||||
Filter for runs created before this ISO 8601 timestamp
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="period" type="string" optional>
|
||||
Filter for runs created in the last N time period. Examples: `7d`, `30d`, `365d`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="machine" type="string" optional>
|
||||
Filter for runs that match this machine preset. Options: `micro`, `small-1x`, `small-2x`,
|
||||
`medium-1x`, `medium-2x`, `large-1x`, `large-2x`
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "list_runs",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123",
|
||||
"status": "COMPLETED",
|
||||
"limit": 10,
|
||||
"period": "7d"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"Cancel run run_abc123"_
|
||||
- _"Stop that task"_
|
||||
|
||||
## Deployment Tools
|
||||
|
||||
### deploy
|
||||
|
||||
Deploy a project to staging or production environments.
|
||||
Deploy your project to staging or production.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
**Example usage:**
|
||||
- _"Deploy to production"_
|
||||
- _"Deploy to staging"_
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
### list_deploys
|
||||
|
||||
<ParamField query="environment" type="string" default="prod">
|
||||
The environment to deploy to. Options: `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
List deployments for a project. Filter by status or time period.
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to deploy, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="skipPromotion" type="boolean" optional>
|
||||
Skip promoting the deployment to the current deployment for the environment
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="skipSyncEnvVars" type="boolean" optional>
|
||||
Skip syncing environment variables when using the syncEnvVars extension
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="skipUpdateCheck" type="boolean" optional>
|
||||
Skip checking for @trigger.dev package updates
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "deploy",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123",
|
||||
"environment": "prod",
|
||||
"skipUpdateCheck": true
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### list_deployments
|
||||
|
||||
List deployments for a project with comprehensive filtering options.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup).
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="environment" type="string" default="prod">
|
||||
The environment to list deployments for. Options: `staging`, `prod`, `preview`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="branch" type="string" optional>
|
||||
The branch to list deployments from, only used for preview environments
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="cursor" type="string" optional>
|
||||
The deployment ID to start the search from, to get the next page
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="limit" type="number" optional>
|
||||
The number of deployments to return, defaults to 20 (max 100)
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="status" type="string" optional>
|
||||
Filter deployments that are in this status. Options: `PENDING`, `BUILDING`, `DEPLOYING`, `DEPLOYED`, `FAILED`, `CANCELED`, `TIMED_OUT`
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="from" type="string" optional>
|
||||
The date to start the search from, in ISO 8601 format
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="to" type="string" optional>
|
||||
The date to end the search, in ISO 8601 format
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="period" type="string" optional>
|
||||
The period to search within. Examples: `1d`, `7d`, `3h`
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "list_deployments",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123",
|
||||
"environment": "prod",
|
||||
"status": "DEPLOYED",
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"Show me recent deployments"_
|
||||
- _"What's deployed to production?"_
|
||||
|
||||
### list_preview_branches
|
||||
|
||||
List all preview branches in the project.
|
||||
|
||||
<ParamField query="projectRef" type="string" optional>
|
||||
The trigger.dev project ref, starts with `proj_`. We will attempt to automatically detect the
|
||||
project ref if running inside a directory that includes a trigger.config.ts file.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="configPath" type="string" optional>
|
||||
The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the
|
||||
root dir (like in a monorepo setup). If not provided, we will try to find the config file in the
|
||||
current working directory.
|
||||
</ParamField>
|
||||
|
||||
<CodeGroup>
|
||||
```json Example Usage
|
||||
{
|
||||
"tool": "list_preview_branches",
|
||||
"arguments": {
|
||||
"projectRef": "proj_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
**Example usage:**
|
||||
- _"What preview branches exist?"_
|
||||
- _"Show me preview deployments"_
|
||||
|
||||
<Callout type="warning">
|
||||
The deploy tool and list_preview_branches tool are not available when the MCP server is running with the `--dev-only` flag.
|
||||
The deploy and list_preview_branches tools are not available when the MCP server is running with the `--dev-only` flag.
|
||||
</Callout>
|
||||
|
||||
+10
-18
@@ -8,29 +8,14 @@ import CliDevStep from '/snippets/step-cli-dev.mdx';
|
||||
import CliRunTestStep from '/snippets/step-run-test.mdx';
|
||||
import CliViewRunStep from '/snippets/step-view-run.mdx';
|
||||
|
||||
In this guide we will:
|
||||
|
||||
1. Create a `trigger.config.ts` file and a `/trigger` directory with an example task.
|
||||
2. Get you to run the task using the CLI.
|
||||
3. Show you how to view the run logs for that task.
|
||||
|
||||
|
||||
<Steps titleSize="h3">
|
||||
|
||||
<Step title="Create a Trigger.dev account">
|
||||
|
||||
You can either:
|
||||
|
||||
- Use the [Trigger.dev Cloud](https://cloud.trigger.dev).
|
||||
- Or [self-host](/open-source-self-hosting) the service.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create your first project">
|
||||
|
||||
Once you've created an account, follow the steps in the app to:
|
||||
|
||||
1. Complete your account details.
|
||||
2. Create your first Organization and Project.
|
||||
Sign up at [Trigger.dev Cloud](https://cloud.trigger.dev) (or [self-host](/open-source-self-hosting)). The onboarding flow will guide you through creating your first organization and project.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -43,11 +28,18 @@ Once you've created an account, follow the steps in the app to:
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup>
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Building with AI" icon="brain" href="/building-with-ai">
|
||||
Learn how to build Trigger.dev projects using AI coding assistants
|
||||
</Card>
|
||||
<Card title="How to trigger your tasks" icon="bolt" href="/triggering">
|
||||
Learn how to trigger tasks from your code.
|
||||
</Card>
|
||||
<Card title="Writing tasks" icon="wand-magic-sparkles" href="/tasks/overview">
|
||||
Tasks are the core of Trigger.dev. Learn what they are and how to write them.
|
||||
</Card>
|
||||
<Card title="Guides and example projects" icon="books" href="/guides/introduction">
|
||||
Guides and examples for triggering tasks from your code.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
@@ -8,6 +8,8 @@ description: "Get compute duration and cost from inside a run, or for a specific
|
||||
You can get the cost and duration of the current including retries of the same run.
|
||||
|
||||
```ts
|
||||
import { task, usage, wait } from "@trigger.dev/sdk";
|
||||
|
||||
export const heavyTask = task({
|
||||
id: "heavy-task",
|
||||
machine: {
|
||||
@@ -87,6 +89,8 @@ console.log("Total cost", totalCost);
|
||||
You can also wrap code with `usage.measure` to get the cost and duration of that block of code:
|
||||
|
||||
```ts
|
||||
import { usage, logger } from "@trigger.dev/sdk";
|
||||
|
||||
// Inside a task run function, or inside a function that's called from there.
|
||||
const { result, compute } = await usage.measure(async () => {
|
||||
//...Do something for 1 second
|
||||
|
||||
@@ -354,6 +354,8 @@ TRIGGER_IMAGE_TAG=v4.0.0
|
||||
docker compose logs -f webapp
|
||||
```
|
||||
|
||||
- **Deploy fails with `ERROR: schema "graphile_worker" does not exist`.** This error occurs when Graphile Worker migrations fail to run during webapp startup. Check the webapp logs for certificate-related errors like `self-signed certificate in certificate chain`. This is often caused by PostgreSQL SSL certificate issues when using an external PostgreSQL instance with SSL enabled. Ensure that both the webapp and supervisor containers have access to the same CA certificate used by your PostgreSQL instance. You can configure this by mounting the certificate file and setting the `NODE_EXTRA_CA_CERTS` environment variable to point to the certificate path. Once the certificate issue is resolved, the migrations will complete and create the required `graphile_worker` schema.
|
||||
|
||||
## CLI usage
|
||||
|
||||
This section highlights some of the CLI commands and options that are useful when self-hosting. Please check the [CLI reference](/cli-introduction) for more in-depth documentation.
|
||||
|
||||
@@ -555,6 +555,7 @@ kubectl delete namespace trigger
|
||||
- **Deploy fails**: Verify registry access and authentication
|
||||
- **Pods stuck pending**: Describe the pod and check the events
|
||||
- **Worker token issues**: Check webapp and supervisor logs for errors
|
||||
- **Deploy fails with `ERROR: schema "graphile_worker" does not exist`**: See the [Docker troubleshooting](/self-hosting/docker#troubleshooting) section for details on resolving PostgreSQL SSL certificate issues that prevent Graphile Worker migrations.
|
||||
|
||||
See the [Docker troubleshooting](/self-hosting/docker#troubleshooting) section for more information.
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: "Skills"
|
||||
description: "Install Trigger.dev skills to teach any AI coding assistant best practices for writing tasks, agents, and workflows."
|
||||
sidebarTitle: "Skills"
|
||||
tag: "new"
|
||||
---
|
||||
|
||||
## What are agent skills?
|
||||
|
||||
Skills are portable instruction sets that teach AI coding assistants how to use Trigger.dev effectively. Unlike vendor-specific config files (`.cursor/rules`, `CLAUDE.md`), skills use an open standard that works across all major AI assistants. For example, Cursor users and Claude Code users can get the same knowledge from a single install.
|
||||
|
||||
Skills are installed as directories containing a `SKILL.md` file. Each `SKILL.md` includes YAML frontmatter (name, description) and markdown instructions with patterns, examples, and best practices that AI assistants automatically discover and follow.
|
||||
|
||||
## Installation
|
||||
|
||||
When you run `npx skills add triggerdotdev/skills`, the CLI detects your installed AI tools and copies the appropriate files to each tool's expected location. For example, `.claude/skills/`, `.cursor/skills/`, `.github/skills/`, etc.
|
||||
|
||||
```bash
|
||||
npx skills add triggerdotdev/skills
|
||||
```
|
||||
|
||||
<Note>`skills` is an open-source CLI by Vercel. Learn more at [skills.sh](https://skills.sh).</Note>
|
||||
|
||||
The result: your AI assistant understands Trigger.dev's specific patterns for exports, schema validation, error handling, retries, and more.
|
||||
|
||||
|
||||
## Available skills
|
||||
|
||||
Install all skills at once, or pick the ones relevant to your current work:
|
||||
|
||||
```bash
|
||||
# Install all Trigger.dev skills
|
||||
npx skills add triggerdotdev/skills
|
||||
|
||||
# Or install individual skills
|
||||
npx skills add triggerdotdev/skills --skill trigger-tasks
|
||||
npx skills add triggerdotdev/skills --skill trigger-agents
|
||||
npx skills add triggerdotdev/skills --skill trigger-config
|
||||
npx skills add triggerdotdev/skills --skill trigger-realtime
|
||||
npx skills add triggerdotdev/skills --skill trigger-setup
|
||||
```
|
||||
|
||||
| Skill | Use for | Covers |
|
||||
|-------|---------|--------|
|
||||
| `trigger-setup` | First time setup, new projects | SDK install, `npx trigger init`, project structure |
|
||||
| `trigger-tasks` | Writing background tasks, async workflows, scheduled tasks | Triggering, waits, queues, retries, cron, metadata |
|
||||
| `trigger-agents` | LLM workflows, orchestration, multi-step AI agents | Prompt chaining, routing, parallelization, human-in-the-loop |
|
||||
| `trigger-realtime` | Live updates, progress indicators, streaming | React hooks, progress bars, streaming AI responses |
|
||||
| `trigger-config` | Project setup, build configuration | `trigger.config.ts`, extensions (Prisma, FFmpeg, Playwright) |
|
||||
|
||||
Not sure which skill to install? Install `trigger-tasks`; it covers the most common patterns for writing Trigger.dev tasks.
|
||||
|
||||
|
||||
## Supported AI assistants
|
||||
|
||||
Skills work with any AI coding assistant that supports the [Agent Skills standard](https://agentskills.io), including:
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Cursor](https://cursor.com)
|
||||
- [Windsurf](https://codeium.com/windsurf)
|
||||
- [GitHub Copilot](https://github.com/features/copilot)
|
||||
- [Cline](https://github.com/cline/cline)
|
||||
- [Codex CLI](https://github.com/openai/codex)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
||||
- [OpenCode](https://opencode.ai)
|
||||
- [View all →](https://skills.sh)
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="MCP Server" icon="sparkles" href="/mcp-introduction">
|
||||
Give your AI assistant direct access to Trigger.dev tools and APIs.
|
||||
</Card>
|
||||
<Card title="Writing tasks" icon="code" href="/tasks/overview">
|
||||
Learn the task patterns that skills teach your AI assistant.
|
||||
</Card>
|
||||
<Card title="Building AI agents" icon="brain" href="/guides/ai-agents/overview">
|
||||
Build durable AI workflows with prompt chaining and human-in-the-loop.
|
||||
</Card>
|
||||
<Card title="skills.sh" icon="box" href="https://skills.sh">
|
||||
Browse the full Agent Skills ecosystem.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -20,12 +20,19 @@ yarn dlx trigger.dev@latest init
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
It will do a few things:
|
||||
|
||||
1. Log you into the CLI if you're not already logged in.
|
||||
2. Create a `trigger.config.ts` file in the root of your project.
|
||||
3. Ask where you'd like to create the `/trigger` directory.
|
||||
4. Create the `/trigger` directory with an example task, `/trigger/example.[ts/js]`.
|
||||
<Tip title="MCP Server">
|
||||
Our [Trigger.dev MCP server](/mcp-introduction) gives your AI assistant direct access to Trigger.dev tools; search docs, trigger tasks, deploy projects, and monitor runs. We recommend installing it for the best developer experience.
|
||||
</Tip>
|
||||
|
||||
1. Ask if you want to install the [Trigger.dev MCP server](/mcp-introduction) for your AI assistant.
|
||||
2. Log you into the CLI if you're not already logged in.
|
||||
3. Ask you to select your project.
|
||||
4. Install the required SDK packages.
|
||||
5. Ask where you'd like to create the `/trigger` directory and create it with an example task.
|
||||
6. Create a `trigger.config.ts` file in the root of your project.
|
||||
|
||||
Install the "Hello World" example task when prompted. We'll use this task to test the setup.
|
||||
|
||||
|
||||
+29
-1
@@ -1,3 +1,31 @@
|
||||
button~.absolute.peer-hover\:opacity-100 {
|
||||
color: #000
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Code block colors - Trigger.dark theme */
|
||||
--mint-color-background: #121317;
|
||||
--mint-color-text: #D4D4D4;
|
||||
--mint-token-constant: #9B99FF;
|
||||
--mint-token-string: #AFEC73;
|
||||
--mint-token-comment: #5F6570;
|
||||
--mint-token-keyword: #E888F8;
|
||||
--mint-token-parameter: #CCCBFF;
|
||||
--mint-token-function: #D9F07C;
|
||||
--mint-token-string-expression: #AFEC73;
|
||||
--mint-token-punctuation: #878C99;
|
||||
--mint-token-link: #826DFF;
|
||||
|
||||
/* Shiki css-variables fallbacks */
|
||||
--shiki-foreground: #D4D4D4;
|
||||
--shiki-background: #121317;
|
||||
--shiki-token-constant: #9B99FF;
|
||||
--shiki-token-string: #AFEC73;
|
||||
--shiki-token-comment: #5F6570;
|
||||
--shiki-token-keyword: #E888F8;
|
||||
--shiki-token-parameter: #CCCBFF;
|
||||
--shiki-token-function: #D9F07C;
|
||||
--shiki-token-string-expression: #AFEC73;
|
||||
--shiki-token-punctuation: #878C99;
|
||||
--shiki-token-link: #826DFF;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ Your code is deployed separately from the rest of your app(s) so you need to mak
|
||||
|
||||
Prisma uses code generation to create the client from your schema file. This means you need to add a bit of config so we can generate this file before your tasks run: [Read the guide](/config/extensions/prismaExtension).
|
||||
|
||||
### Database connection requires IPv4
|
||||
|
||||
Trigger.dev currently only supports IPv4 database connections. If your database provider only provides an IPv6 connection string, you'll need to use an IPv4 address instead. [Upvote IPv6 support](https://triggerdev.featurebase.app/p/support-ipv6-database-connections).
|
||||
|
||||
### `Parallel waits are not supported`
|
||||
|
||||
In the current version, you can't perform more that one "wait" in parallel.
|
||||
@@ -171,12 +175,52 @@ The most common situation this happens is if you're using `Promise.all` around s
|
||||
|
||||
Make sure that you always use `await` when you call `trigger`, `triggerAndWait`, `batchTrigger`, and `batchTriggerAndWait`. If you don't then it's likely the task(s) won't be triggered because the calling function process can be terminated before the networks calls are sent.
|
||||
|
||||
### `COULD_NOT_FIND_EXECUTOR`
|
||||
|
||||
If you see a `COULD_NOT_FIND_EXECUTOR` error when triggering a task, it may be caused by dynamically importing the child task. When tasks are dynamically imported, the executor may not be properly registered.
|
||||
|
||||
Use a top-level import instead:
|
||||
|
||||
```ts
|
||||
import { myChildTask } from "~/trigger/my-child-task";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
await myChildTask.trigger({ payload: "data" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, use `tasks.trigger()` or `batch.triggerAndWait()` without importing the task:
|
||||
|
||||
```ts
|
||||
import { batch } from "@trigger.dev/sdk";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: string) => {
|
||||
await batch.triggerAndWait([{ id: "my-child-task", payload: "data" }]);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Rate limit exceeded
|
||||
|
||||
<RateLimitHitUseBatchTrigger />
|
||||
|
||||
View the [rate limits](/limits) page for more information.
|
||||
|
||||
### Runs waiting in queue due to concurrency limits
|
||||
|
||||
If runs are staying in the `QUEUED` state for extended periods, check your concurrency usage in the dashboard. Review how many runs are `EXECUTING` or `DEQUEUED` (these count against limits) and check if any runs are stuck in `EXECUTING` state, as they may be blocking new runs.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- **Increase concurrency limits** - If you're on a paid plan, increase your environment concurrency limit via the dashboard
|
||||
- **Review queue concurrency limits** - Check if individual queues have restrictive `concurrencyLimit` settings
|
||||
- **Check for stuck runs** - See if stalled runs are blocking new executions
|
||||
|
||||
### `Crypto is not defined`
|
||||
|
||||
This can happen in different situations, for example when using plain strings as idempotency keys. Support for `Crypto` without a special flag was added in Node `v19.0.0`. You will have to upgrade Node - we recommend even-numbered major releases, e.g. `v20` or `v22`. Alternatively, you can switch from plain strings to the `idempotencyKeys.create` SDK function. [Read the guide](/idempotency).
|
||||
|
||||
@@ -505,6 +505,234 @@ paths:
|
||||
|
||||
await runs.cancel("run_1234");
|
||||
|
||||
"/api/v1/deployments/{deploymentId}":
|
||||
parameters:
|
||||
- in: path
|
||||
name: deploymentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The deployment ID.
|
||||
get:
|
||||
operationId: get_deployment_v1
|
||||
summary: Get deployment
|
||||
description: Retrieve information about a specific deployment by its ID.
|
||||
responses:
|
||||
"200":
|
||||
description: Successful request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The deployment ID
|
||||
status:
|
||||
type: string
|
||||
enum: ["PENDING", "INSTALLING", "BUILDING", "DEPLOYING", "DEPLOYED", "FAILED", "CANCELED", "TIMED_OUT"]
|
||||
description: The current status of the deployment
|
||||
contentHash:
|
||||
type: string
|
||||
description: Hash of the deployment content
|
||||
shortCode:
|
||||
type: string
|
||||
description: The short code for the deployment
|
||||
version:
|
||||
type: string
|
||||
description: The deployment version (e.g., "20250228.1")
|
||||
imageReference:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Reference to the deployment image
|
||||
imagePlatform:
|
||||
type: string
|
||||
description: Platform of the deployment image
|
||||
externalBuildData:
|
||||
type: object
|
||||
nullable: true
|
||||
description: External build data if applicable
|
||||
errorData:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Error data if the deployment failed
|
||||
worker:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Worker information if available
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
tasks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
filePath:
|
||||
type: string
|
||||
exportName:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized - Access token is missing or invalid
|
||||
"404":
|
||||
description: Deployment not found
|
||||
tags:
|
||||
- deployments
|
||||
security:
|
||||
- secretKey: []
|
||||
x-codeSamples:
|
||||
- lang: typescript
|
||||
source: |-
|
||||
const response = await fetch(
|
||||
`https://api.trigger.dev/api/v1/deployments/${deploymentId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${secretKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const deployment = await response.json();
|
||||
- lang: curl
|
||||
source: |-
|
||||
curl -X GET "https://api.trigger.dev/api/v1/deployments/deployment_1234" \
|
||||
-H "Authorization: Bearer tr_dev_1234"
|
||||
|
||||
"/api/v1/deployments/latest":
|
||||
get:
|
||||
operationId: get_latest_deployment_v1
|
||||
summary: Get latest deployment
|
||||
description: Retrieve information about the latest unmanaged deployment for the authenticated project.
|
||||
responses:
|
||||
"200":
|
||||
description: Successful request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The deployment ID
|
||||
status:
|
||||
type: string
|
||||
enum: ["PENDING", "INSTALLING", "BUILDING", "DEPLOYING", "DEPLOYED", "FAILED", "CANCELED", "TIMED_OUT"]
|
||||
description: The current status of the deployment
|
||||
contentHash:
|
||||
type: string
|
||||
description: Hash of the deployment content
|
||||
shortCode:
|
||||
type: string
|
||||
description: The short code for the deployment
|
||||
version:
|
||||
type: string
|
||||
description: The deployment version (e.g., "20250228.1")
|
||||
imageReference:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Reference to the deployment image
|
||||
errorData:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Error data if the deployment failed
|
||||
"401":
|
||||
description: Unauthorized - API key is missing or invalid
|
||||
"404":
|
||||
description: No deployment found
|
||||
tags:
|
||||
- deployments
|
||||
security:
|
||||
- secretKey: []
|
||||
x-codeSamples:
|
||||
- lang: typescript
|
||||
source: |-
|
||||
const response = await fetch(
|
||||
"https://api.trigger.dev/api/v1/deployments/latest",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${secretKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const deployment = await response.json();
|
||||
- lang: curl
|
||||
source: |-
|
||||
curl -X GET "https://api.trigger.dev/api/v1/deployments/latest" \
|
||||
-H "Authorization: Bearer tr_dev_1234"
|
||||
|
||||
"/api/v1/deployments/{version}/promote":
|
||||
parameters:
|
||||
- in: path
|
||||
name: version
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The deployment version to promote (e.g., "20250228.1").
|
||||
post:
|
||||
operationId: promote_deployment_v1
|
||||
summary: Promote deployment
|
||||
description: Promote a previously deployed version to be the current version for the environment. This makes the specified version active for new task runs.
|
||||
responses:
|
||||
"200":
|
||||
description: Deployment promoted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The deployment ID
|
||||
version:
|
||||
type: string
|
||||
description: The deployment version (e.g., "20250228.1")
|
||||
shortCode:
|
||||
type: string
|
||||
description: The short code for the deployment
|
||||
"400":
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized - API key is missing or invalid
|
||||
"404":
|
||||
description: Deployment not found
|
||||
tags:
|
||||
- deployments
|
||||
security:
|
||||
- secretKey: []
|
||||
x-codeSamples:
|
||||
- lang: typescript
|
||||
source: |-
|
||||
const response = await fetch(
|
||||
`https://api.trigger.dev/api/v1/deployments/${version}/promote`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${secretKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = await response.json();
|
||||
- lang: curl
|
||||
source: |-
|
||||
curl -X POST "https://api.trigger.dev/api/v1/deployments/20250228.1/promote" \
|
||||
-H "Authorization: Bearer tr_dev_1234" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
"/api/v1/runs/{runId}/reschedule":
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/runId"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- +goose Up
|
||||
-- Update the materialized columns to extract the 'data' field if it exists
|
||||
-- This avoids the {"data": ...} wrapper in the text representation
|
||||
-- Note: Direct JSON path access (output.data) returns null for nested objects,
|
||||
-- so we use JSONExtractRaw on the stringified JSON instead
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN output_text String MATERIALIZED if (
|
||||
toJSONString (output) = '{}',
|
||||
'',
|
||||
if (
|
||||
length (JSONExtractRaw (toJSONString (output), 'data')) > 0,
|
||||
JSONExtractRaw (toJSONString (output), 'data'),
|
||||
toJSONString (output)
|
||||
)
|
||||
);
|
||||
|
||||
-- For error: extract error.data if it exists
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN error_text String MATERIALIZED if (
|
||||
toJSONString (error) = '{}',
|
||||
'',
|
||||
if (
|
||||
length (JSONExtractRaw (toJSONString (error), 'data')) > 0,
|
||||
JSONExtractRaw (toJSONString (error), 'data'),
|
||||
toJSONString (error)
|
||||
)
|
||||
);
|
||||
|
||||
-- Add the indexes
|
||||
ALTER TABLE trigger_dev.task_runs_v2 ADD INDEX idx_output_text output_text TYPE ngrambf_v1 (3, 131072, 3, 0) GRANULARITY 4;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2 ADD INDEX idx_error_text error_text TYPE ngrambf_v1 (3, 131072, 3, 0) GRANULARITY 4;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP INDEX IF EXISTS idx_output_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP INDEX IF EXISTS idx_error_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS output_text;
|
||||
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN IF EXISTS error_text;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Add indexes for text search on task task_events_v2 tables for message and attributes fields
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_attributes_text_search lower(attributes_text)
|
||||
TYPE ngrambf_v1(3, 32768, 2, 0)
|
||||
GRANULARITY 1;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
ADD INDEX IF NOT EXISTS idx_message_text_search lower(message)
|
||||
TYPE ngrambf_v1(3, 32768, 2, 0)
|
||||
GRANULARITY 1;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX idx_attributes_text_search;
|
||||
|
||||
ALTER TABLE trigger_dev.task_events_v2
|
||||
DROP INDEX idx_message_text_search;
|
||||
@@ -2,7 +2,7 @@
|
||||
* TSQL Query Execution for ClickHouse
|
||||
*
|
||||
* This module provides a safe interface for executing TSQL queries against ClickHouse
|
||||
* with automatic tenant isolation and SQL injection protection.
|
||||
* with enforced WHERE clause conditions (tenant isolation + plan limits) and SQL injection protection.
|
||||
*/
|
||||
|
||||
import type { ClickHouseSettings } from "@clickhouse/client";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TableSchema,
|
||||
type QuerySettings,
|
||||
type FieldMappings,
|
||||
type WhereClauseFallback,
|
||||
type WhereClauseCondition
|
||||
} from "@internal/tsql";
|
||||
import type { ClickhouseReader, QueryStats } from "./types.js";
|
||||
import { QueryError } from "./errors.js";
|
||||
@@ -25,7 +25,7 @@ const logger = new Logger("tsql", "info");
|
||||
|
||||
export type { QueryStats };
|
||||
|
||||
export type { TableSchema, QuerySettings, FieldMappings, WhereClauseFallback };
|
||||
export type { TableSchema, QuerySettings, FieldMappings, WhereClauseCondition };
|
||||
|
||||
/**
|
||||
* Options for executing a TSQL query
|
||||
@@ -37,14 +37,26 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
|
||||
query: string;
|
||||
/** The Zod schema for validating output rows */
|
||||
schema: TOut;
|
||||
/** The organization ID for tenant isolation (required) */
|
||||
organizationId: string;
|
||||
/** The project ID for tenant isolation (optional - omit to query across all projects) */
|
||||
projectId?: string;
|
||||
/** The environment ID for tenant isolation (optional - omit to query across all environments) */
|
||||
environmentId?: string;
|
||||
/** Schema registry defining allowed tables and columns */
|
||||
tableSchema: TableSchema[];
|
||||
/**
|
||||
* REQUIRED: Conditions always applied at the table level.
|
||||
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
|
||||
* Applied to every table reference including subqueries, CTEs, and JOINs.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* // Tenant isolation
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* // Plan-based time limit
|
||||
* triggered_at: { op: "gte", value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
enforcedWhereClause: Record<string, WhereClauseCondition | undefined>;
|
||||
/** Optional ClickHouse query settings */
|
||||
clickhouseSettings?: ClickHouseSettings;
|
||||
/** Optional TSQL query settings (maxRows, timezone, etc.) */
|
||||
@@ -78,6 +90,7 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
|
||||
/**
|
||||
* Fallback WHERE conditions to apply when the user hasn't filtered on a column.
|
||||
* Key is the column name, value is the fallback condition.
|
||||
* These are applied at the AST level (top-level query only).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -87,7 +100,7 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
whereClauseFallback?: Record<string, WhereClauseFallback>;
|
||||
whereClauseFallback?: Record<string, WhereClauseCondition>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +115,11 @@ export interface TSQLQuerySuccess<T> {
|
||||
* Only populated when SELECT * is transformed to core columns only.
|
||||
*/
|
||||
hiddenColumns?: string[];
|
||||
/**
|
||||
* Whether the result count equals the maxRows limit.
|
||||
* When true, the results may be truncated and more rows may exist.
|
||||
*/
|
||||
reachedMaxRows: boolean;
|
||||
/**
|
||||
* The raw EXPLAIN output from ClickHouse.
|
||||
* Only populated when `explain: true` is passed.
|
||||
@@ -123,7 +141,7 @@ export type TSQLQueryResult<T> = [QueryError, null] | [null, TSQLQuerySuccess<T>
|
||||
* Execute a TSQL query against ClickHouse
|
||||
*
|
||||
* This function:
|
||||
* 1. Compiles the TSQL query to ClickHouse SQL (parse, validate, inject tenant guards)
|
||||
* 1. Compiles the TSQL query to ClickHouse SQL (parse, validate, inject enforced WHERE clauses)
|
||||
* 2. Executes the query and returns validated results
|
||||
*
|
||||
* @example
|
||||
@@ -132,10 +150,12 @@ export type TSQLQueryResult<T> = [QueryError, null] | [null, TSQLQuerySuccess<T>
|
||||
* name: "get_task_runs",
|
||||
* query: "SELECT id, status FROM task_runs WHERE status = 'completed' ORDER BY created_at DESC LIMIT 100",
|
||||
* schema: z.object({ id: z.string(), status: z.string() }),
|
||||
* organizationId: "org_123",
|
||||
* projectId: "proj_456",
|
||||
* environmentId: "env_789",
|
||||
* tableSchema: [taskRunsSchema],
|
||||
* enforcedWhereClause: {
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -145,18 +165,22 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
const shouldTransformValues = options.transformValues ?? true;
|
||||
const isExplain = options.explain ?? false;
|
||||
const maxRows = options.querySettings?.maxRows;
|
||||
|
||||
let generatedSql: string | undefined;
|
||||
let generatedParams: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
// 1. Compile the TSQL query to ClickHouse SQL
|
||||
// Pass maxRows + 1 to fetch one extra row for overflow detection
|
||||
const compiledSettings = maxRows !== undefined
|
||||
? { ...options.querySettings, maxRows: maxRows + 1 }
|
||||
: options.querySettings;
|
||||
|
||||
const { sql, params, columns, hiddenColumns } = compileTSQL(options.query, {
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
environmentId: options.environmentId,
|
||||
tableSchema: options.tableSchema,
|
||||
settings: options.querySettings,
|
||||
enforcedWhereClause: options.enforcedWhereClause,
|
||||
settings: compiledSettings,
|
||||
fieldMappings: options.fieldMappings,
|
||||
whereClauseFallback: options.whereClauseFallback,
|
||||
});
|
||||
@@ -231,26 +255,36 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
columns: [],
|
||||
stats,
|
||||
hiddenColumns,
|
||||
reachedMaxRows: false,
|
||||
explainOutput: combinedOutput,
|
||||
generatedSql,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Determine if we exceeded maxRows (we fetched maxRows + 1 to detect overflow)
|
||||
const reachedMaxRows = maxRows !== undefined && rows !== undefined && rows.length > maxRows;
|
||||
|
||||
// Remove the overflow row if we got one (pop is O(1), slice would be O(n))
|
||||
const finalRows = rows ?? [];
|
||||
if (reachedMaxRows) {
|
||||
finalRows.pop();
|
||||
}
|
||||
|
||||
// Build the result, including hiddenColumns if present
|
||||
const baseResult = { columns, stats, hiddenColumns };
|
||||
const baseResult = { columns, stats, hiddenColumns, reachedMaxRows };
|
||||
|
||||
// 3. Transform result values if enabled
|
||||
if (shouldTransformValues && rows) {
|
||||
if (shouldTransformValues && finalRows.length > 0) {
|
||||
const transformedRows = transformResults(
|
||||
rows as Record<string, unknown>[],
|
||||
finalRows as Record<string, unknown>[],
|
||||
options.tableSchema,
|
||||
{ fieldMappings: options.fieldMappings }
|
||||
);
|
||||
return [null, { rows: transformedRows as z.output<TOut>[], ...baseResult }];
|
||||
}
|
||||
|
||||
return [null, { rows: rows ?? [], ...baseResult }];
|
||||
return [null, { rows: finalRows as z.output<TOut>[], ...baseResult }];
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -284,9 +318,11 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
|
||||
* name: "get_task_runs",
|
||||
* query: "SELECT * FROM task_runs LIMIT 10",
|
||||
* schema: taskRunRowSchema,
|
||||
* organizationId: "org_123",
|
||||
* projectId: "proj_456",
|
||||
* environmentId: "env_789",
|
||||
* enforcedWhereClause: {
|
||||
* organization_id: { op: "eq", value: "org_123" },
|
||||
* project_id: { op: "eq", value: "proj_456" },
|
||||
* environment_id: { op: "eq", value: "env_789" },
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
insertTaskEventsV2,
|
||||
getLogsListQueryBuilderV2,
|
||||
getLogDetailQueryBuilderV2,
|
||||
getLogsListQueryBuilderV1,
|
||||
getLogDetailQueryBuilderV1,
|
||||
} from "./taskEvents.js";
|
||||
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
|
||||
import type { Agent as HttpAgent } from "http";
|
||||
@@ -56,7 +54,7 @@ export {
|
||||
type TSQLQuerySuccess,
|
||||
type QueryStats,
|
||||
type FieldMappings,
|
||||
type WhereClauseFallback,
|
||||
type WhereClauseCondition,
|
||||
} from "./client/tsql.js";
|
||||
export type { OutputColumnMetadata } from "@internal/tsql";
|
||||
|
||||
@@ -213,8 +211,6 @@ export class ClickHouse {
|
||||
traceSummaryQueryBuilder: getTraceSummaryQueryBuilder(this.reader),
|
||||
traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilder(this.reader),
|
||||
spanDetailsQueryBuilder: getSpanDetailsQueryBuilder(this.reader),
|
||||
logsListQueryBuilder: getLogsListQueryBuilderV1(this.reader, this.logsQuerySettings?.list),
|
||||
logDetailQueryBuilder: getLogDetailQueryBuilderV1(this.reader, this.logsQuerySettings?.detail),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -320,56 +320,4 @@ export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: Clic
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Logs List Query Builders for V1 (task_events_v1)
|
||||
// ============================================================================
|
||||
|
||||
export function getLogsListQueryBuilderV1(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilderFast<LogsListResult>({
|
||||
name: "getLogsListV1",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
columns: [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"task_identifier",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
{ name: "message", expression: "LEFT(message, 512)" },
|
||||
"kind",
|
||||
"status",
|
||||
"duration",
|
||||
"attributes_text"
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export function getLogDetailQueryBuilderV1(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.queryBuilderFast<LogDetailV2Result>({
|
||||
name: "getLogDetailV1",
|
||||
table: "trigger_dev.task_events_v1",
|
||||
columns: [
|
||||
"environment_id",
|
||||
"organization_id",
|
||||
"project_id",
|
||||
"task_identifier",
|
||||
"run_id",
|
||||
"start_time",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"parent_span_id",
|
||||
"message",
|
||||
"kind",
|
||||
"status",
|
||||
"duration",
|
||||
"attributes_text",
|
||||
],
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-simple-select",
|
||||
query: "SELECT run_id, status FROM task_runs",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -145,9 +147,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-where-clause",
|
||||
query: "SELECT run_id, status FROM task_runs WHERE status = 'COMPLETED_SUCCESSFULLY'",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -197,9 +201,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-tenant-isolation-1",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -212,9 +218,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-tenant-isolation-2",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant2",
|
||||
projectId: "proj_tenant2",
|
||||
environmentId: "env_tenant2",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant2" },
|
||||
project_id: { op: "eq", value: "proj_tenant2" },
|
||||
environment_id: { op: "eq", value: "env_tenant2" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -254,9 +262,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-cross-tenant-attack",
|
||||
query: "SELECT run_id, status FROM task_runs WHERE status = 'COMPLETED' OR 1=1",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_attacker",
|
||||
projectId: "proj_attacker",
|
||||
environmentId: "env_attacker",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_attacker" },
|
||||
project_id: { op: "eq", value: "proj_attacker" },
|
||||
environment_id: { op: "eq", value: "env_attacker" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -288,9 +298,11 @@ describe("TSQL Integration Tests", () => {
|
||||
query:
|
||||
"SELECT status, count(*) as cnt FROM task_runs GROUP BY status ORDER BY cnt DESC, status ASC",
|
||||
schema: z.object({ status: z.string(), cnt: z.coerce.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -325,9 +337,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-order-limit",
|
||||
query: "SELECT run_id FROM task_runs ORDER BY created_at DESC LIMIT 2",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -347,9 +361,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-unknown-table",
|
||||
query: "SELECT * FROM unknown_table",
|
||||
schema: z.object({ id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -378,9 +394,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-executor",
|
||||
query: "SELECT run_id, status FROM task_runs WHERE status = 'PENDING'",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
@@ -406,9 +424,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-injection",
|
||||
query: "SELECT run_id, status FROM task_runs WHERE status = 'DROP TABLE task_runs'",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -438,9 +458,11 @@ describe("TSQL Integration Tests", () => {
|
||||
query:
|
||||
"SELECT run_id, status FROM task_runs WHERE status IN ('COMPLETED_SUCCESSFULLY', 'FAILED')",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -467,9 +489,11 @@ describe("TSQL Integration Tests", () => {
|
||||
name: "test-like-query",
|
||||
query: "SELECT run_id, task_identifier FROM task_runs WHERE task_identifier LIKE 'email%'",
|
||||
schema: z.object({ run_id: z.string(), task_identifier: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -530,8 +554,10 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-cross-project-query",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_multi",
|
||||
// projectId and environmentId omitted - query across all
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_multi" },
|
||||
// project_id and environment_id omitted - query across all
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -590,9 +616,11 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-cross-env-query",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_envtest",
|
||||
projectId: "proj_envtest",
|
||||
// environmentId omitted - query across all environments
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_envtest" },
|
||||
project_id: { op: "eq", value: "proj_envtest" },
|
||||
// environment_id omitted - query across all environments
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -649,8 +677,10 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-org-isolation-1",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_isolation_1",
|
||||
// projectId and environmentId omitted
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_isolation_1" },
|
||||
// project_id and environment_id omitted
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -663,8 +693,10 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-org-isolation-2",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_isolation_2",
|
||||
// projectId and environmentId omitted
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_isolation_2" },
|
||||
// project_id and environment_id omitted
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -706,8 +738,10 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-or-bypass-attempt",
|
||||
query: "SELECT run_id, status FROM task_runs WHERE status = 'COMPLETED' OR 1=1",
|
||||
schema: z.object({ run_id: z.string(), status: z.string() }),
|
||||
organizationId: "org_attacker",
|
||||
// No project/env filter - but org filter should still protect
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_attacker" },
|
||||
// No project/env filter - but org filter should still protect
|
||||
},
|
||||
tableSchema: [taskRunsSchema],
|
||||
});
|
||||
|
||||
@@ -751,8 +785,10 @@ describe("TSQL Optional Tenant Filter Tests", () => {
|
||||
name: "test-executor-optional",
|
||||
query: "SELECT run_id FROM task_runs",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_executor_test",
|
||||
// projectId and environmentId omitted
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_executor_test" },
|
||||
// project_id and environment_id omitted
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeNull();
|
||||
@@ -839,9 +875,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
execution_duration: z.number().nullable(),
|
||||
usage_duration_seconds: z.number(),
|
||||
}),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [virtualColumnSchema],
|
||||
});
|
||||
|
||||
@@ -889,9 +927,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
name: "test-virtual-column-where",
|
||||
query: "SELECT run_id FROM task_runs WHERE execution_duration > 5000",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [virtualColumnSchema],
|
||||
});
|
||||
|
||||
@@ -935,9 +975,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
run_id: z.string(),
|
||||
usage_duration_seconds: z.number(),
|
||||
}),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [virtualColumnSchema],
|
||||
});
|
||||
|
||||
@@ -977,9 +1019,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
run_id: z.string(),
|
||||
dur_sec: z.number(),
|
||||
}),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [virtualColumnSchema],
|
||||
});
|
||||
|
||||
@@ -1013,9 +1057,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
run_id: z.string(),
|
||||
execution_duration: z.number().nullable(),
|
||||
}),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [virtualColumnSchema],
|
||||
});
|
||||
|
||||
@@ -1110,9 +1156,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
name: "test-expression-division-where",
|
||||
query: "SELECT run_id, invocation_cost FROM task_runs WHERE invocation_cost > 1.0",
|
||||
schema: z.object({ run_id: z.string(), invocation_cost: z.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1153,9 +1201,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
name: "test-expression-gte-where",
|
||||
query: "SELECT run_id, invocation_cost FROM task_runs WHERE invocation_cost >= 1.0",
|
||||
schema: z.object({ run_id: z.string(), invocation_cost: z.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1192,9 +1242,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
name: "test-expression-lt-where",
|
||||
query: "SELECT run_id, invocation_cost FROM task_runs WHERE invocation_cost < 1.0",
|
||||
schema: z.object({ run_id: z.string(), invocation_cost: z.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1236,9 +1288,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
query:
|
||||
"SELECT run_id, invocation_cost FROM task_runs WHERE invocation_cost BETWEEN 1.0 AND 2.0",
|
||||
schema: z.object({ run_id: z.string(), invocation_cost: z.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1282,9 +1336,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
query:
|
||||
"SELECT run_id FROM task_runs WHERE status = 'COMPLETED_SUCCESSFULLY' AND invocation_cost > 2.0",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1328,9 +1384,11 @@ describe("TSQL Virtual Column Tests", () => {
|
||||
name: "test-expression-large-integer-where",
|
||||
query: "SELECT run_id, invocation_cost FROM task_runs WHERE invocation_cost > 100",
|
||||
schema: z.object({ run_id: z.string(), invocation_cost: z.number() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [costExpressionSchema],
|
||||
});
|
||||
|
||||
@@ -1393,9 +1451,11 @@ describe("Field Mapping Tests", () => {
|
||||
name: "test-field-mapping-select",
|
||||
query: "SELECT run_id, project_ref FROM task_runs",
|
||||
schema: z.object({ run_id: z.string(), project_ref: z.string().nullable() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [fieldMappingSchema],
|
||||
fieldMappings: {
|
||||
project: {
|
||||
@@ -1434,9 +1494,11 @@ describe("Field Mapping Tests", () => {
|
||||
name: "test-field-mapping-unmapped",
|
||||
query: "SELECT run_id, project_ref FROM task_runs WHERE run_id = 'run_fm_unmapped'",
|
||||
schema: z.object({ run_id: z.string(), project_ref: z.string().nullable() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [fieldMappingSchema],
|
||||
fieldMappings: {
|
||||
project: {
|
||||
@@ -1481,9 +1543,11 @@ describe("Field Mapping Tests", () => {
|
||||
name: "test-field-mapping-where",
|
||||
query: "SELECT run_id FROM task_runs WHERE project_ref = 'my-project-ref'",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
projectId: "proj_tenant1",
|
||||
environmentId: "env_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
project_id: { op: "eq", value: "proj_tenant1" },
|
||||
environment_id: { op: "eq", value: "env_tenant1" },
|
||||
},
|
||||
tableSchema: [fieldMappingSchema],
|
||||
fieldMappings: {
|
||||
project: {
|
||||
@@ -1530,7 +1594,9 @@ describe("Field Mapping Tests", () => {
|
||||
query:
|
||||
"SELECT run_id FROM task_runs WHERE project_ref IN ('my-project-ref', 'other-project')",
|
||||
schema: z.object({ run_id: z.string() }),
|
||||
organizationId: "org_tenant1",
|
||||
enforcedWhereClause: {
|
||||
organization_id: { op: "eq", value: "org_tenant1" },
|
||||
},
|
||||
tableSchema: [fieldMappingSchema],
|
||||
fieldMappings: {
|
||||
project: {
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerQuery"
|
||||
ADD COLUMN IF NOT EXISTS "title" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerQuery"
|
||||
DROP COLUMN IF EXISTS "costInCents";
|
||||
@@ -2452,8 +2452,8 @@ model CustomerQuery {
|
||||
/// Query execution statistics from ClickHouse
|
||||
stats Json
|
||||
|
||||
/// Cost of the query in cents (for Stripe metering)
|
||||
costInCents Float @default(0)
|
||||
/// AI-generated title summarizing the query
|
||||
title String?
|
||||
|
||||
/// Where the query originated from
|
||||
source CustomerQuerySource @default(DASHBOARD)
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type Result,
|
||||
} from "@internal/redis";
|
||||
import { startSpan } from "@internal/tracing";
|
||||
import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
parseNaturalLanguageDuration,
|
||||
parseNaturalLanguageDurationInMs,
|
||||
} from "@trigger.dev/core/v3/isomorphic";
|
||||
import { PrismaClientOrTransaction, TaskRun, Waitpoint } from "@trigger.dev/database";
|
||||
import { nanoid } from "nanoid";
|
||||
import { SystemResources } from "./systems.js";
|
||||
@@ -17,6 +20,12 @@ export type DebounceOptions = {
|
||||
key: string;
|
||||
delay: string;
|
||||
mode?: "leading" | "trailing";
|
||||
/**
|
||||
* Maximum total delay before the run must execute, regardless of subsequent triggers.
|
||||
* This prevents indefinite delays when continuous triggers keep pushing the execution time.
|
||||
* If not specified, falls back to the server's maxDebounceDurationMs config.
|
||||
*/
|
||||
maxDelay?: string;
|
||||
/** When mode: "trailing", these fields will be used to update the existing run */
|
||||
updateData?: {
|
||||
payload: string;
|
||||
@@ -521,8 +530,22 @@ return 0
|
||||
}
|
||||
|
||||
// Check if max debounce duration would be exceeded
|
||||
// Use per-trigger maxDelay if provided, otherwise use global config
|
||||
let maxDurationMs = this.maxDebounceDurationMs;
|
||||
if (debounce.maxDelay) {
|
||||
const parsedMaxDelay = parseNaturalLanguageDurationInMs(debounce.maxDelay);
|
||||
if (parsedMaxDelay !== undefined) {
|
||||
maxDurationMs = parsedMaxDelay;
|
||||
} else {
|
||||
this.$.logger.warn("handleExistingRun: invalid maxDelay duration, using global config", {
|
||||
maxDelay: debounce.maxDelay,
|
||||
fallbackMs: this.maxDebounceDurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runCreatedAt = existingRun.createdAt;
|
||||
const maxDelayUntil = new Date(runCreatedAt.getTime() + this.maxDebounceDurationMs);
|
||||
const maxDelayUntil = new Date(runCreatedAt.getTime() + maxDurationMs);
|
||||
|
||||
if (newDelayUntil > maxDelayUntil) {
|
||||
this.$.logger.debug("handleExistingRun: max debounce duration would be exceeded", {
|
||||
@@ -531,7 +554,8 @@ return 0
|
||||
runCreatedAt,
|
||||
newDelayUntil,
|
||||
maxDelayUntil,
|
||||
maxDebounceDurationMs: this.maxDebounceDurationMs,
|
||||
maxDurationMs,
|
||||
maxDelayProvided: debounce.maxDelay,
|
||||
});
|
||||
// Clean up Redis key since this debounce window is closed
|
||||
await this.redis.del(redisKey);
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
TaskRunExecutionSnapshot,
|
||||
TaskRunExecutionStatus,
|
||||
TaskRunStatus,
|
||||
Waitpoint,
|
||||
} from "@trigger.dev/database";
|
||||
import { HeartbeatTimeouts } from "../types.js";
|
||||
import { SystemResources } from "./systems.js";
|
||||
|
||||
/** Chunk size for fetching waitpoints to avoid NAPI string conversion limits */
|
||||
const WAITPOINT_CHUNK_SIZE = 100;
|
||||
|
||||
export type ExecutionSnapshotSystemOptions = {
|
||||
resources: SystemResources;
|
||||
heartbeatTimeouts: HeartbeatTimeouts;
|
||||
@@ -31,19 +35,41 @@ type ExecutionSnapshotWithCheckAndWaitpoints = Prisma.TaskRunExecutionSnapshotGe
|
||||
};
|
||||
}>;
|
||||
|
||||
type ExecutionSnapshotWithCheckpoint = Prisma.TaskRunExecutionSnapshotGetPayload<{
|
||||
include: {
|
||||
checkpoint: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
function enhanceExecutionSnapshot(
|
||||
snapshot: ExecutionSnapshotWithCheckAndWaitpoints
|
||||
): EnhancedExecutionSnapshot {
|
||||
return enhanceExecutionSnapshotWithWaitpoints(
|
||||
snapshot,
|
||||
snapshot.completedWaitpoints,
|
||||
snapshot.completedWaitpointOrder
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a snapshot (with checkpoint but without waitpoints) into an EnhancedExecutionSnapshot
|
||||
* by combining it with pre-fetched waitpoints.
|
||||
*/
|
||||
function enhanceExecutionSnapshotWithWaitpoints(
|
||||
snapshot: ExecutionSnapshotWithCheckpoint,
|
||||
waitpoints: Waitpoint[],
|
||||
completedWaitpointOrder: string[]
|
||||
): EnhancedExecutionSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
friendlyId: SnapshotId.toFriendlyId(snapshot.id),
|
||||
runFriendlyId: RunId.toFriendlyId(snapshot.runId),
|
||||
completedWaitpoints: snapshot.completedWaitpoints.flatMap((w) => {
|
||||
//get all indexes of the waitpoint in the completedWaitpointOrder
|
||||
//we do this because the same run can be in a batch multiple times (i.e. same idempotencyKey)
|
||||
completedWaitpoints: waitpoints.flatMap((w) => {
|
||||
// Get all indexes of the waitpoint in the completedWaitpointOrder
|
||||
// We do this because the same run can be in a batch multiple times (i.e. same idempotencyKey)
|
||||
let indexes: (number | undefined)[] = [];
|
||||
for (let i = 0; i < snapshot.completedWaitpointOrder.length; i++) {
|
||||
if (snapshot.completedWaitpointOrder[i] === w.id) {
|
||||
for (let i = 0; i < completedWaitpointOrder.length; i++) {
|
||||
if (completedWaitpointOrder[i] === w.id) {
|
||||
indexes.push(i);
|
||||
}
|
||||
}
|
||||
@@ -60,9 +86,7 @@ function enhanceExecutionSnapshot(
|
||||
type: w.type,
|
||||
completedAt: w.completedAt ?? new Date(),
|
||||
idempotencyKey:
|
||||
w.userProvidedIdempotencyKey && !w.inactiveIdempotencyKey
|
||||
? w.idempotencyKey
|
||||
: undefined,
|
||||
w.userProvidedIdempotencyKey && !w.inactiveIdempotencyKey ? w.idempotencyKey : undefined,
|
||||
completedByTaskRun: w.completedByTaskRunId
|
||||
? {
|
||||
id: w.completedByTaskRunId,
|
||||
@@ -91,6 +115,42 @@ function enhanceExecutionSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the waitpoint IDs linked to a snapshot via the _completedWaitpoints join table.
|
||||
* Uses raw SQL to avoid fetching full waitpoint data.
|
||||
*/
|
||||
async function getSnapshotWaitpointIds(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
snapshotId: string
|
||||
): Promise<string[]> {
|
||||
const result = await prisma.$queryRaw<{ B: string }[]>`
|
||||
SELECT "B" FROM "_completedWaitpoints" WHERE "A" = ${snapshotId}
|
||||
`;
|
||||
return result.map((r) => r.B);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches waitpoints in chunks to avoid NAPI string conversion limits.
|
||||
* This is necessary because waitpoints can have large outputs (100KB+),
|
||||
* and fetching many at once can exceed Node.js string limits.
|
||||
*/
|
||||
async function fetchWaitpointsInChunks(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
waitpointIds: string[]
|
||||
): Promise<Waitpoint[]> {
|
||||
if (waitpointIds.length === 0) return [];
|
||||
|
||||
const allWaitpoints: Waitpoint[] = [];
|
||||
for (let i = 0; i < waitpointIds.length; i += WAITPOINT_CHUNK_SIZE) {
|
||||
const chunk = waitpointIds.slice(i, i + WAITPOINT_CHUNK_SIZE);
|
||||
const waitpoints = await prisma.waitpoint.findMany({
|
||||
where: { id: { in: chunk } },
|
||||
});
|
||||
allWaitpoints.push(...waitpoints);
|
||||
}
|
||||
return allWaitpoints;
|
||||
}
|
||||
|
||||
/* Gets the most recent valid snapshot for a run */
|
||||
export async function getLatestExecutionSnapshot(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
@@ -191,12 +251,27 @@ export function executionDataFromSnapshot(snapshot: EnhancedExecutionSnapshot):
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets execution snapshots created after the specified snapshot.
|
||||
*
|
||||
* IMPORTANT: This function is optimized to avoid N×M data explosion when runs have many
|
||||
* completed waitpoints. Due to the many-to-many relation, once waitpoints complete,
|
||||
* all subsequent snapshots have the same waitpoints linked. For a run with 24 snapshots
|
||||
* and 236 waitpoints with 100KB outputs each, fetching all waitpoints for all snapshots
|
||||
* would result in ~570MB of data, causing "Failed to convert rust String into napi string" errors.
|
||||
*
|
||||
* Solution: Only the LATEST snapshot's waitpoints are fetched and included. The runner's
|
||||
* SnapshotManager only processes completedWaitpoints from the latest snapshot anyway -
|
||||
* intermediate snapshots' waitpoints are ignored. This reduces data from N×M to just M.
|
||||
*
|
||||
* Waitpoints are fetched in chunks (100 at a time) to handle batches up to 1000 items.
|
||||
*/
|
||||
export async function getExecutionSnapshotsSince(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
runId: string,
|
||||
sinceSnapshotId: string
|
||||
): Promise<EnhancedExecutionSnapshot[]> {
|
||||
// Find the createdAt of the sinceSnapshotId
|
||||
// Step 1: Find the createdAt of the sinceSnapshotId
|
||||
const sinceSnapshot = await prisma.taskRunExecutionSnapshot.findFirst({
|
||||
where: { id: sinceSnapshotId },
|
||||
select: { createdAt: true },
|
||||
@@ -206,6 +281,7 @@ export async function getExecutionSnapshotsSince(
|
||||
throw new Error(`No execution snapshot found for id ${sinceSnapshotId}`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch snapshots WITHOUT waitpoints to avoid N×M data explosion
|
||||
const snapshots = await prisma.taskRunExecutionSnapshot.findMany({
|
||||
where: {
|
||||
runId,
|
||||
@@ -213,14 +289,32 @@ export async function getExecutionSnapshotsSince(
|
||||
createdAt: { gt: sinceSnapshot.createdAt },
|
||||
},
|
||||
include: {
|
||||
completedWaitpoints: true,
|
||||
checkpoint: true,
|
||||
// DO NOT include completedWaitpoints here - this causes the N×M explosion
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return snapshots.reverse().map(enhanceExecutionSnapshot);
|
||||
if (snapshots.length === 0) return [];
|
||||
|
||||
// Step 3: Get waitpoint IDs for the LATEST snapshot only (first in desc order)
|
||||
const latestSnapshot = snapshots[0];
|
||||
const waitpointIds = await getSnapshotWaitpointIds(prisma, latestSnapshot.id);
|
||||
|
||||
// Step 4: Fetch waitpoints in chunks to avoid NAPI string conversion limits
|
||||
const waitpoints = await fetchWaitpointsInChunks(prisma, waitpointIds);
|
||||
|
||||
// Step 5: Build enhanced snapshots - only latest gets waitpoints, others get empty arrays
|
||||
// The runner only uses completedWaitpoints from the latest snapshot anyway
|
||||
return snapshots.reverse().map((snapshot) => {
|
||||
const isLatest = snapshot.id === latestSnapshot.id;
|
||||
return enhanceExecutionSnapshotWithWaitpoints(
|
||||
snapshot,
|
||||
isLatest ? waitpoints : [],
|
||||
latestSnapshot.completedWaitpointOrder
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class ExecutionSnapshotSystem {
|
||||
|
||||
@@ -2170,5 +2170,332 @@ describe("RunEngine debounce", () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"Debounce: per-trigger maxDelay overrides global maxDebounceDuration",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Set a long global max debounce duration (1 minute)
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
debounce: {
|
||||
maxDebounceDurationMs: 60_000, // 1 minute global max
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
// First trigger with a very short per-trigger maxDelay (1 second)
|
||||
const run1 = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: "run_maxwait1",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "first"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12345",
|
||||
spanId: "s12345",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 5000),
|
||||
debounce: {
|
||||
key: "maxwait-key",
|
||||
delay: "5s",
|
||||
maxDelay: "1s", // Very short per-trigger maxDelay (1 second)
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
expect(run1.friendlyId).toBe("run_maxwait1");
|
||||
|
||||
// Wait for the per-trigger maxDelay to be exceeded (1.5s > 1s)
|
||||
await setTimeout(1500);
|
||||
|
||||
// Second trigger should create a new run because per-trigger maxDelay exceeded
|
||||
// (even though global maxDebounceDurationMs is 60 seconds)
|
||||
const run2 = await engine.trigger(
|
||||
{
|
||||
number: 2,
|
||||
friendlyId: "run_maxwait2",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "second"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12346",
|
||||
spanId: "s12346",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 5000),
|
||||
debounce: {
|
||||
key: "maxwait-key",
|
||||
delay: "5s",
|
||||
maxDelay: "1s",
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Should be a different run because per-trigger maxDelay was exceeded
|
||||
expect(run2.id).not.toBe(run1.id);
|
||||
expect(run2.friendlyId).toBe("run_maxwait2");
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"Debounce: falls back to global config when maxDelay not specified",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Set a very short global max debounce duration (1 second)
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
debounce: {
|
||||
maxDebounceDurationMs: 1000, // 1 second global max
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
// First trigger without maxDelay - should use global config
|
||||
const run1 = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: "run_noglobal1",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "first"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12345",
|
||||
spanId: "s12345",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 5000),
|
||||
debounce: {
|
||||
key: "global-fallback-key",
|
||||
delay: "5s",
|
||||
// No maxDelay specified - should use global maxDebounceDurationMs
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Wait for global maxDebounceDurationMs to be exceeded (1.5s > 1s)
|
||||
await setTimeout(1500);
|
||||
|
||||
// Second trigger should create a new run because global max exceeded
|
||||
const run2 = await engine.trigger(
|
||||
{
|
||||
number: 2,
|
||||
friendlyId: "run_noglobal2",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "second"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12346",
|
||||
spanId: "s12346",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 5000),
|
||||
debounce: {
|
||||
key: "global-fallback-key",
|
||||
delay: "5s",
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Should be a different run because global max exceeded
|
||||
expect(run2.id).not.toBe(run1.id);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"Debounce: long maxDelay allows more debounce time than global config",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Set a short global max debounce duration (1 second)
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
debounce: {
|
||||
maxDebounceDurationMs: 1000, // 1 second global max
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
// First trigger with long maxDelay that overrides the short global config
|
||||
const run1 = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: "run_longmax1",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "first"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12345",
|
||||
spanId: "s12345",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 2000),
|
||||
debounce: {
|
||||
key: "long-maxwait-key",
|
||||
delay: "2s",
|
||||
maxDelay: "60s", // Long per-trigger maxDelay overrides short global config
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Wait past the global maxDebounceDurationMs (1s) but within our per-trigger maxDelay (60s)
|
||||
await setTimeout(1500);
|
||||
|
||||
// Second trigger should return SAME run because per-trigger maxDelay is 60s
|
||||
const run2 = await engine.trigger(
|
||||
{
|
||||
number: 2,
|
||||
friendlyId: "run_longmax2",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: '{"data": "second"}',
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12346",
|
||||
spanId: "s12346",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
delayUntil: new Date(Date.now() + 2000),
|
||||
debounce: {
|
||||
key: "long-maxwait-key",
|
||||
delay: "2s",
|
||||
maxDelay: "60s",
|
||||
},
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Should be the SAME run because per-trigger maxDelay allows it
|
||||
expect(run2.id).toBe(run1.id);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import { trace } from "@internal/tracing";
|
||||
import { expect, describe } from "vitest";
|
||||
import { RunEngine } from "../index.js";
|
||||
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import {
|
||||
generateTestScenarios,
|
||||
type SnapshotTestScenario,
|
||||
} from "./helpers/executionStateMachine.js";
|
||||
import {
|
||||
createWaitpointsWithOutput,
|
||||
setupTestScenario,
|
||||
generateLargeOutput,
|
||||
} from "./helpers/snapshotTestHelpers.js";
|
||||
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
describe("RunEngine getSnapshotsSince", () => {
|
||||
containerTest(
|
||||
"returns empty array when querying from latest snapshot",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const run = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t_empty",
|
||||
spanId: "s_empty",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
await setTimeout(500);
|
||||
const dequeued = await engine.dequeueFromWorkerQueue({
|
||||
consumerId: "test_empty",
|
||||
workerQueue: "main",
|
||||
});
|
||||
|
||||
// Get all snapshots
|
||||
const allSnapshots = await prisma.taskRunExecutionSnapshot.findMany({
|
||||
where: { runId: run.id, isValid: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
expect(allSnapshots.length).toBeGreaterThan(0);
|
||||
|
||||
// Query from the last snapshot
|
||||
const lastSnapshot = allSnapshots[allSnapshots.length - 1];
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: run.id,
|
||||
snapshotId: lastSnapshot.id,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBe(0);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"returns snapshots after the specified one with waitpoints only on latest",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const run = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t_wp",
|
||||
spanId: "s_wp",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
await setTimeout(500);
|
||||
const dequeued = await engine.dequeueFromWorkerQueue({
|
||||
consumerId: "test_wp",
|
||||
workerQueue: "main",
|
||||
});
|
||||
|
||||
// Start attempt
|
||||
await engine.startRunAttempt({
|
||||
runId: dequeued[0].run.id,
|
||||
snapshotId: dequeued[0].snapshot.id,
|
||||
});
|
||||
|
||||
// Create and block with a waitpoint
|
||||
const { waitpoint } = await engine.createDateTimeWaitpoint({
|
||||
projectId: authenticatedEnvironment.project.id,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
completedAfter: new Date(Date.now() + 50),
|
||||
});
|
||||
|
||||
await engine.blockRunWithWaitpoint({
|
||||
runId: run.id,
|
||||
waitpoints: [waitpoint.id],
|
||||
projectId: authenticatedEnvironment.project.id,
|
||||
organizationId: authenticatedEnvironment.organization.id,
|
||||
});
|
||||
|
||||
// Wait for waitpoint completion
|
||||
await setTimeout(200);
|
||||
|
||||
// Get all snapshots
|
||||
const allSnapshots = await prisma.taskRunExecutionSnapshot.findMany({
|
||||
where: { runId: run.id, isValid: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
expect(allSnapshots.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Query from the first snapshot
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: run.id,
|
||||
snapshotId: allSnapshots[0].id,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The latest snapshot should have completedWaitpoints
|
||||
const latest = result![result!.length - 1];
|
||||
expect(latest.completedWaitpoints.length).toBeGreaterThan(0);
|
||||
|
||||
// Earlier snapshots should have empty waitpoints (optimization)
|
||||
for (let i = 0; i < result!.length - 1; i++) {
|
||||
expect(result![i].completedWaitpoints.length).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"handles multiple waitpoints correctly - only latest has them",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const run = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t_mwp",
|
||||
spanId: "s_mwp",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
await setTimeout(500);
|
||||
const dequeued = await engine.dequeueFromWorkerQueue({
|
||||
consumerId: "test_mwp",
|
||||
workerQueue: "main",
|
||||
});
|
||||
|
||||
await engine.startRunAttempt({
|
||||
runId: dequeued[0].run.id,
|
||||
snapshotId: dequeued[0].snapshot.id,
|
||||
});
|
||||
|
||||
// Create multiple waitpoints
|
||||
const waitpointCount = 5;
|
||||
const waitpointPromises = Array.from({ length: waitpointCount }).map(() =>
|
||||
engine.createManualWaitpoint({
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
})
|
||||
);
|
||||
const waitpoints = await Promise.all(waitpointPromises);
|
||||
|
||||
// Block the run with all waitpoints
|
||||
for (const { waitpoint } of waitpoints) {
|
||||
await engine.blockRunWithWaitpoint({
|
||||
runId: run.id,
|
||||
waitpoints: waitpoint.id,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Complete all waitpoints
|
||||
for (const { waitpoint } of waitpoints) {
|
||||
await engine.completeWaitpoint({ id: waitpoint.id });
|
||||
}
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
// Get all snapshots
|
||||
const allSnapshots = await prisma.taskRunExecutionSnapshot.findMany({
|
||||
where: { runId: run.id, isValid: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
// Query from early in the sequence
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: run.id,
|
||||
snapshotId: allSnapshots[0].id,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBeGreaterThan(0);
|
||||
|
||||
// Only the latest should have waitpoints
|
||||
const latest = result![result!.length - 1];
|
||||
|
||||
// Earlier snapshots must have empty completedWaitpoints
|
||||
for (let i = 0; i < result!.length - 1; i++) {
|
||||
expect(result![i].completedWaitpoints.length).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest("returns null for invalid snapshot ID", async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const run = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t_invalid",
|
||||
spanId: "s_invalid",
|
||||
workerQueue: "main",
|
||||
queue: "task/test-task",
|
||||
isTest: false,
|
||||
tags: [],
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
// Query with invalid snapshot ID
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: run.id,
|
||||
snapshotId: "invalid-snapshot-id",
|
||||
});
|
||||
|
||||
// Should return null (caught by getSnapshotsSince error handler)
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Direct database tests for the core function
|
||||
containerTest(
|
||||
"direct test: large waitpoint scenario - 100 waitpoints with 10KB outputs",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
// Create scenario directly in database
|
||||
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
|
||||
totalWaitpoints: 100,
|
||||
outputSizeKB: 10,
|
||||
snapshotConfigs: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 50 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 100 },
|
||||
{ status: "FINISHED", completedWaitpointCount: 100 },
|
||||
],
|
||||
});
|
||||
|
||||
// Query from early snapshot
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: scenario.run.id,
|
||||
snapshotId: scenario.snapshots[2].id, // After PENDING_EXECUTING
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBe(6); // EXECUTING through FINISHED
|
||||
|
||||
// Latest should have all 100 waitpoints
|
||||
const latest = result![result!.length - 1];
|
||||
expect(latest.completedWaitpoints.length).toBe(100);
|
||||
|
||||
// Verify all earlier snapshots have empty waitpoints
|
||||
for (let i = 0; i < result!.length - 1; i++) {
|
||||
expect(result![i].completedWaitpoints.length).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"direct test: zombie run scenario - 236 waitpoints with 100KB outputs, 24 snapshots",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
// This scenario matches the exact conditions that caused the NAPI error
|
||||
// 24 snapshots × 236 waitpoints × 100KB = ~570MB if not optimized
|
||||
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
|
||||
totalWaitpoints: 236,
|
||||
outputSizeKB: 100,
|
||||
snapshotConfigs: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 200 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(scenario.snapshots.length).toBe(24);
|
||||
expect(scenario.waitpoints.length).toBe(236);
|
||||
|
||||
// Query from the 6th snapshot (after waitpoints start completing)
|
||||
const queryFromIndex = 5;
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: scenario.run.id,
|
||||
snapshotId: scenario.snapshots[queryFromIndex].id,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Should return snapshots after index 5, which is 24 - 6 = 18 snapshots
|
||||
expect(result!.length).toBe(24 - queryFromIndex - 1);
|
||||
|
||||
// Latest should have all 236 waitpoints
|
||||
const latest = result![result!.length - 1];
|
||||
expect(latest.completedWaitpoints.length).toBe(236);
|
||||
|
||||
// All other snapshots should have 0 waitpoints (optimization)
|
||||
for (let i = 0; i < result!.length - 1; i++) {
|
||||
expect(result![i].completedWaitpoints.length).toBe(0);
|
||||
}
|
||||
|
||||
// Verify the outputs are present and correct size
|
||||
for (const wp of latest.completedWaitpoints) {
|
||||
expect(wp.output).toBeDefined();
|
||||
// ~100KB output as JSON string
|
||||
expect(typeof wp.output).toBe("string");
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"direct test: verifies chunked fetching works with 500+ waitpoints",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0001,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
try {
|
||||
// 500 waitpoints requires 5 chunks (100 per chunk)
|
||||
const scenario = await setupTestScenario(prisma, authenticatedEnvironment, {
|
||||
totalWaitpoints: 500,
|
||||
outputSizeKB: 10, // Smaller outputs for faster test
|
||||
snapshotConfigs: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 500 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 500 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await engine.getSnapshotsSince({
|
||||
runId: scenario.run.id,
|
||||
snapshotId: scenario.snapshots[0].id,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.length).toBe(4);
|
||||
|
||||
const latest = result![result!.length - 1];
|
||||
expect(latest.completedWaitpoints.length).toBe(500);
|
||||
|
||||
// All other snapshots should be empty
|
||||
for (let i = 0; i < result!.length - 1; i++) {
|
||||
expect(result![i].completedWaitpoints.length).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { TaskRunExecutionStatus } from "@trigger.dev/database";
|
||||
|
||||
/**
|
||||
* Defines valid execution status transitions for the Run Engine 2.0.
|
||||
* This is a model of the state machine that governs run execution.
|
||||
*/
|
||||
export const EXECUTION_STATUS_TRANSITIONS: Record<
|
||||
TaskRunExecutionStatus,
|
||||
TaskRunExecutionStatus[]
|
||||
> = {
|
||||
RUN_CREATED: ["QUEUED", "DELAYED"],
|
||||
DELAYED: ["QUEUED"],
|
||||
QUEUED: ["PENDING_EXECUTING", "QUEUED_EXECUTING"],
|
||||
QUEUED_EXECUTING: ["PENDING_EXECUTING", "QUEUED"],
|
||||
PENDING_EXECUTING: ["EXECUTING", "PENDING_CANCEL", "FINISHED", "QUEUED"],
|
||||
EXECUTING: ["EXECUTING_WITH_WAITPOINTS", "FINISHED", "PENDING_CANCEL", "QUEUED"],
|
||||
EXECUTING_WITH_WAITPOINTS: ["EXECUTING", "SUSPENDED", "FINISHED", "PENDING_CANCEL"],
|
||||
SUSPENDED: ["QUEUED", "PENDING_CANCEL", "FINISHED"],
|
||||
PENDING_CANCEL: ["FINISHED"],
|
||||
FINISHED: ["QUEUED"], // Retry case
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a transition from one status to another is valid.
|
||||
*/
|
||||
export function isValidTransition(
|
||||
from: TaskRunExecutionStatus,
|
||||
to: TaskRunExecutionStatus
|
||||
): boolean {
|
||||
return EXECUTION_STATUS_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a snapshot in a test scenario.
|
||||
*/
|
||||
export interface SnapshotConfig {
|
||||
/** The execution status for this snapshot */
|
||||
status: TaskRunExecutionStatus;
|
||||
/** Number of waitpoints completed at this snapshot (cumulative) */
|
||||
completedWaitpointCount: number;
|
||||
/** Whether this snapshot has a checkpoint */
|
||||
hasCheckpoint?: boolean;
|
||||
/** Description for the snapshot */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A test scenario for getSnapshotsSince testing.
|
||||
*/
|
||||
export interface SnapshotTestScenario {
|
||||
/** Unique name for the scenario */
|
||||
name: string;
|
||||
/** Description of what this scenario tests */
|
||||
description: string;
|
||||
/** Total number of waitpoints to create */
|
||||
totalWaitpoints: number;
|
||||
/** Size of each waitpoint's output in KB */
|
||||
outputSizeKB: number;
|
||||
/** Configuration for each snapshot to create */
|
||||
snapshots: SnapshotConfig[];
|
||||
/** Which snapshot index to query "since" (0-based) */
|
||||
queryFromIndex: number;
|
||||
/** Expected number of waitpoints on the latest snapshot returned */
|
||||
expectedWaitpointsOnLatest: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates test scenarios for comprehensive getSnapshotsSince testing.
|
||||
* These scenarios cover various edge cases and stress tests.
|
||||
*/
|
||||
export function generateTestScenarios(): SnapshotTestScenario[] {
|
||||
return [
|
||||
{
|
||||
name: "simple_no_waitpoints",
|
||||
description: "Basic run without any waitpoints",
|
||||
totalWaitpoints: 0,
|
||||
outputSizeKB: 0,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "FINISHED", completedWaitpointCount: 0 },
|
||||
],
|
||||
queryFromIndex: 0,
|
||||
expectedWaitpointsOnLatest: 0,
|
||||
},
|
||||
{
|
||||
name: "single_small_waitpoint",
|
||||
description: "Single waitpoint with small output",
|
||||
totalWaitpoints: 1,
|
||||
outputSizeKB: 1,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 1 },
|
||||
],
|
||||
queryFromIndex: 2,
|
||||
expectedWaitpointsOnLatest: 1,
|
||||
},
|
||||
{
|
||||
name: "batch_100_medium",
|
||||
description: "Medium batch with 100 waitpoints and medium outputs",
|
||||
totalWaitpoints: 100,
|
||||
outputSizeKB: 10,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 50 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 100, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 100 },
|
||||
{ status: "FINISHED", completedWaitpointCount: 100 },
|
||||
],
|
||||
queryFromIndex: 3,
|
||||
expectedWaitpointsOnLatest: 100,
|
||||
},
|
||||
{
|
||||
name: "batch_236_large_zombie_scenario",
|
||||
description:
|
||||
"Matches the zombie run scenario: 24 snapshots, 236 waitpoints, 100KB outputs each",
|
||||
totalWaitpoints: 236,
|
||||
outputSizeKB: 100,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 50 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 150 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 200 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
],
|
||||
queryFromIndex: 6,
|
||||
expectedWaitpointsOnLatest: 236,
|
||||
},
|
||||
{
|
||||
name: "batch_500_large",
|
||||
description: "Large batch requiring chunked fetching",
|
||||
totalWaitpoints: 500,
|
||||
outputSizeKB: 50,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 250 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 400 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 500 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 500, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 500 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 500 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 500 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 500, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 500 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 500 },
|
||||
],
|
||||
queryFromIndex: 5,
|
||||
expectedWaitpointsOnLatest: 500,
|
||||
},
|
||||
{
|
||||
name: "system_failure_finished",
|
||||
description: "Latest snapshot is FINISHED status with completed waitpoints",
|
||||
totalWaitpoints: 100,
|
||||
outputSizeKB: 50,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 50 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 100 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 100 },
|
||||
{ status: "FINISHED", completedWaitpointCount: 100 },
|
||||
],
|
||||
queryFromIndex: 3,
|
||||
expectedWaitpointsOnLatest: 100,
|
||||
},
|
||||
{
|
||||
name: "query_from_latest",
|
||||
description: "Querying from the latest snapshot should return empty array",
|
||||
totalWaitpoints: 10,
|
||||
outputSizeKB: 10,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 10 },
|
||||
],
|
||||
queryFromIndex: 4, // The last snapshot
|
||||
expectedWaitpointsOnLatest: 0, // No snapshots returned, so no waitpoints
|
||||
},
|
||||
{
|
||||
name: "requeue_loop",
|
||||
description: "Multiple QUEUED->PENDING_EXECUTING cycles with waitpoints",
|
||||
totalWaitpoints: 236,
|
||||
outputSizeKB: 100,
|
||||
snapshots: [
|
||||
{ status: "RUN_CREATED", completedWaitpointCount: 0 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 0 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 0 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 }, // Requeued
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 }, // Requeued again
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 }, // Requeued
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 }, // Requeued again
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING_WITH_WAITPOINTS", completedWaitpointCount: 236 },
|
||||
{ status: "SUSPENDED", completedWaitpointCount: 236, hasCheckpoint: true },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 },
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "QUEUED", completedWaitpointCount: 236 }, // Requeued
|
||||
{ status: "PENDING_EXECUTING", completedWaitpointCount: 236 },
|
||||
{ status: "EXECUTING", completedWaitpointCount: 236 },
|
||||
],
|
||||
queryFromIndex: 7,
|
||||
expectedWaitpointsOnLatest: 236,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { generateFriendlyId, WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
PrismaClient,
|
||||
TaskRunExecutionSnapshot,
|
||||
TaskRunExecutionStatus,
|
||||
Waitpoint,
|
||||
WaitpointStatus,
|
||||
} from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "../setup.js";
|
||||
|
||||
/**
|
||||
* Generates a large output string of the specified size in KB.
|
||||
* The output is a valid JSON string to simulate realistic waitpoint output.
|
||||
*/
|
||||
export function generateLargeOutput(sizeKB: number): string {
|
||||
if (sizeKB <= 0) return JSON.stringify({ data: "" });
|
||||
|
||||
// Create a string that's approximately the target size
|
||||
// Account for JSON wrapper overhead
|
||||
const targetBytes = sizeKB * 1024;
|
||||
const overhead = JSON.stringify({ data: "" }).length;
|
||||
const payloadSize = Math.max(0, targetBytes - overhead);
|
||||
|
||||
// Generate a payload of repeating 'x' characters
|
||||
const payload = "x".repeat(payloadSize);
|
||||
return JSON.stringify({ data: payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates waitpoints with specified output sizes for testing.
|
||||
*/
|
||||
export async function createWaitpointsWithOutput(
|
||||
prisma: PrismaClient,
|
||||
count: number,
|
||||
outputSizeKB: number,
|
||||
environmentId: string,
|
||||
projectId: string
|
||||
): Promise<Waitpoint[]> {
|
||||
if (count === 0) return [];
|
||||
|
||||
const output = generateLargeOutput(outputSizeKB);
|
||||
const waitpoints: Waitpoint[] = [];
|
||||
|
||||
// Create waitpoints in batches to avoid overwhelming the database
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < count; i += batchSize) {
|
||||
const batchCount = Math.min(batchSize, count - i);
|
||||
const batch = await Promise.all(
|
||||
Array.from({ length: batchCount }).map(async (_, j) => {
|
||||
const waitpointIds = WaitpointId.generate();
|
||||
return prisma.waitpoint.create({
|
||||
data: {
|
||||
id: waitpointIds.id,
|
||||
friendlyId: waitpointIds.friendlyId,
|
||||
type: "MANUAL",
|
||||
status: "COMPLETED" as WaitpointStatus,
|
||||
idempotencyKey: `test-idempotency-${waitpointIds.id}`,
|
||||
userProvidedIdempotencyKey: false,
|
||||
completedAt: new Date(),
|
||||
output,
|
||||
outputType: "application/json",
|
||||
outputIsError: false,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
waitpoints.push(...batch);
|
||||
}
|
||||
|
||||
return waitpoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot directly in the database for testing purposes.
|
||||
* This bypasses the normal engine flow to allow creating specific test scenarios.
|
||||
*/
|
||||
export async function createTestSnapshot(
|
||||
prisma: PrismaClient,
|
||||
{
|
||||
runId,
|
||||
status,
|
||||
environmentId,
|
||||
environmentType,
|
||||
projectId,
|
||||
organizationId,
|
||||
completedWaitpointIds,
|
||||
checkpointId,
|
||||
previousSnapshotId,
|
||||
batchId,
|
||||
workerId,
|
||||
runnerId,
|
||||
attemptNumber,
|
||||
}: {
|
||||
runId: string;
|
||||
status: TaskRunExecutionStatus;
|
||||
environmentId: string;
|
||||
environmentType: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW";
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
completedWaitpointIds?: string[];
|
||||
checkpointId?: string;
|
||||
previousSnapshotId?: string;
|
||||
batchId?: string;
|
||||
workerId?: string;
|
||||
runnerId?: string;
|
||||
attemptNumber?: number;
|
||||
}
|
||||
): Promise<TaskRunExecutionSnapshot> {
|
||||
// Determine run status based on execution status
|
||||
const runStatus = getRunStatusFromExecutionStatus(status);
|
||||
|
||||
const snapshot = await prisma.taskRunExecutionSnapshot.create({
|
||||
data: {
|
||||
engine: "V2",
|
||||
executionStatus: status,
|
||||
description: `Test snapshot: ${status}`,
|
||||
previousSnapshotId,
|
||||
runId,
|
||||
runStatus,
|
||||
attemptNumber,
|
||||
batchId,
|
||||
environmentId,
|
||||
environmentType,
|
||||
projectId,
|
||||
organizationId,
|
||||
checkpointId,
|
||||
workerId,
|
||||
runnerId,
|
||||
isValid: true,
|
||||
completedWaitpoints: completedWaitpointIds
|
||||
? {
|
||||
connect: completedWaitpointIds.map((id) => ({ id })),
|
||||
}
|
||||
: undefined,
|
||||
completedWaitpointOrder: completedWaitpointIds ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
// Small delay to ensure different createdAt timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps execution status to run status for test snapshot creation.
|
||||
*/
|
||||
function getRunStatusFromExecutionStatus(
|
||||
status: TaskRunExecutionStatus
|
||||
): "PENDING" | "EXECUTING" | "WAITING_FOR_DEPLOY" | "COMPLETED_SUCCESSFULLY" | "SYSTEM_FAILURE" {
|
||||
switch (status) {
|
||||
case "RUN_CREATED":
|
||||
case "QUEUED":
|
||||
case "QUEUED_EXECUTING":
|
||||
case "PENDING_EXECUTING":
|
||||
case "DELAYED":
|
||||
return "PENDING";
|
||||
case "EXECUTING":
|
||||
case "EXECUTING_WITH_WAITPOINTS":
|
||||
case "SUSPENDED":
|
||||
case "PENDING_CANCEL":
|
||||
return "EXECUTING";
|
||||
case "FINISHED":
|
||||
return "COMPLETED_SUCCESSFULLY";
|
||||
default:
|
||||
return "PENDING";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a checkpoint for testing suspended snapshots.
|
||||
*/
|
||||
export async function createTestCheckpoint(
|
||||
prisma: PrismaClient,
|
||||
{
|
||||
runId,
|
||||
environmentId,
|
||||
projectId,
|
||||
}: {
|
||||
runId: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
}
|
||||
) {
|
||||
return prisma.taskRunCheckpoint.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("checkpoint"),
|
||||
type: "DOCKER",
|
||||
location: `s3://test-bucket/checkpoints/${runId}`,
|
||||
imageRef: `test-image:${runId}`,
|
||||
reason: "WAIT_FOR_DURATION",
|
||||
runtimeEnvironment: {
|
||||
connect: { id: environmentId },
|
||||
},
|
||||
project: {
|
||||
connect: { id: projectId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a complete test scenario setup result.
|
||||
*/
|
||||
export interface TestScenarioResult {
|
||||
run: {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
};
|
||||
snapshots: TaskRunExecutionSnapshot[];
|
||||
waitpoints: Waitpoint[];
|
||||
checkpoints: Array<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a complete test scenario with run, snapshots, waitpoints, and checkpoints.
|
||||
* This creates the full database state needed for testing getSnapshotsSince.
|
||||
*/
|
||||
export async function setupTestScenario(
|
||||
prisma: PrismaClient,
|
||||
environment: AuthenticatedEnvironment,
|
||||
{
|
||||
totalWaitpoints,
|
||||
outputSizeKB,
|
||||
snapshotConfigs,
|
||||
}: {
|
||||
totalWaitpoints: number;
|
||||
outputSizeKB: number;
|
||||
snapshotConfigs: Array<{
|
||||
status: TaskRunExecutionStatus;
|
||||
completedWaitpointCount: number;
|
||||
hasCheckpoint?: boolean;
|
||||
}>;
|
||||
}
|
||||
): Promise<TestScenarioResult> {
|
||||
// Create waitpoints first
|
||||
const waitpoints = await createWaitpointsWithOutput(
|
||||
prisma,
|
||||
totalWaitpoints,
|
||||
outputSizeKB,
|
||||
environment.id,
|
||||
environment.project.id
|
||||
);
|
||||
|
||||
// Create the run
|
||||
const runFriendlyId = generateFriendlyId("run");
|
||||
const run = await prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: runFriendlyId,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organization.id,
|
||||
projectId: environment.project.id,
|
||||
taskIdentifier: "test-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceId: `trace_${runFriendlyId}`,
|
||||
spanId: `span_${runFriendlyId}`,
|
||||
context: {},
|
||||
traceContext: {},
|
||||
isTest: false,
|
||||
queue: "task/test-task",
|
||||
workerQueue: "main",
|
||||
},
|
||||
});
|
||||
|
||||
// Create snapshots in order
|
||||
const snapshots: TaskRunExecutionSnapshot[] = [];
|
||||
const checkpoints: Array<{ id: string }> = [];
|
||||
let previousSnapshotId: string | undefined;
|
||||
let attemptNumber = 0;
|
||||
|
||||
for (const config of snapshotConfigs) {
|
||||
// Create checkpoint if needed
|
||||
let checkpointId: string | undefined;
|
||||
if (config.hasCheckpoint) {
|
||||
const checkpoint = await createTestCheckpoint(prisma, {
|
||||
runId: run.id,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.project.id,
|
||||
});
|
||||
checkpointId = checkpoint.id;
|
||||
checkpoints.push({ id: checkpoint.id });
|
||||
}
|
||||
|
||||
// Increment attempt number when entering a new execution attempt
|
||||
// PENDING_EXECUTING is the entry point - EXECUTING follows within the same attempt
|
||||
if (config.status === "PENDING_EXECUTING") {
|
||||
attemptNumber++;
|
||||
}
|
||||
|
||||
// Get the waitpoint IDs that should be "completed" at this snapshot
|
||||
const completedWaitpointIds = waitpoints.slice(0, config.completedWaitpointCount).map((w) => w.id);
|
||||
|
||||
const snapshot = await createTestSnapshot(prisma, {
|
||||
runId: run.id,
|
||||
status: config.status,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
projectId: environment.project.id,
|
||||
organizationId: environment.organization.id,
|
||||
completedWaitpointIds,
|
||||
checkpointId,
|
||||
previousSnapshotId,
|
||||
attemptNumber,
|
||||
});
|
||||
|
||||
snapshots.push(snapshot);
|
||||
previousSnapshotId = snapshot.id;
|
||||
}
|
||||
|
||||
return {
|
||||
run: { id: run.id, friendlyId: runFriendlyId },
|
||||
snapshots,
|
||||
waitpoints,
|
||||
checkpoints,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,7 @@ export type TriggerParams = {
|
||||
key: string;
|
||||
delay: string;
|
||||
mode?: "leading" | "trailing";
|
||||
maxDelay?: string;
|
||||
};
|
||||
/**
|
||||
* Called when a run is debounced (existing delayed run found with triggerAndWait).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@internal/sdk-compat-tests",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0",
|
||||
"execa": "^9.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "3.1.4"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user