Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 076f7579d1 | |||
| edf5b142fc | |||
| 4c3dfac223 | |||
| 7ccbbdb368 | |||
| 71279a7b12 | |||
| 2eba36c086 | |||
| 29827e96f2 | |||
| d416f340ad | |||
| 7a54a843e2 | |||
| 52e9baede5 | |||
| deb80890fe | |||
| d82089686c | |||
| acc10e847c | |||
| f1a83cffc4 | |||
| 61fee91830 | |||
| caa40ce925 | |||
| ba9b0e17c1 | |||
| 469808cf09 | |||
| 7574c69c2d | |||
| 06cbe6e3ca | |||
| 3875bb292a | |||
| ff80742ab7 | |||
| 11366e658c | |||
| e751f8832e | |||
| a999d9ea3f | |||
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
fix(cli): update command should preserve existing package.json order
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: how to create and apply database migrations
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
|
||||
+7
-1
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
|
||||
|
||||
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
|
||||
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 🦋 Changeset PR
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🚀 Publish Trigger.dev Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
|
||||
@@ -7,13 +7,21 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: true
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
@@ -22,19 +30,35 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name != 'workflow_dispatch' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
startsWith(github.event.pull_request.head.ref, 'changeset-release/')
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
@@ -44,6 +68,15 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -56,6 +89,11 @@ jobs:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -100,7 +138,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch'
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -108,13 +146,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Validate ref is on main
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -138,7 +169,7 @@ jobs:
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.tag }}
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -149,6 +180,6 @@ jobs:
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.tag }}
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
+2
-1
@@ -62,4 +62,5 @@ apps/**/public/build
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.mcp.log
|
||||
.mcp.log
|
||||
.cursor/debug.log
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Build and deploy fully‑managed AI agents and workflows
|
||||
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
## Creating and applying migrations
|
||||
|
||||
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
|
||||
|
||||
Edit the `schema.prisma` file to add or modify the schema.
|
||||
|
||||
Create a new migration file but don't apply it yet:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
|
||||
```
|
||||
|
||||
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
All the following lines should be removed:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
Leaving only this:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
```
|
||||
|
||||
After editing the migration file, apply the migration:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
@@ -244,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -252,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
|
||||
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to adjacent">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -331,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -340,6 +340,7 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -372,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -387,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -408,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-charcoal-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,59 +3,95 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Click to copy"}
|
||||
className="font-sans px-2 py-1"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,10 +78,12 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
export const allBatchStatuses = [
|
||||
"PROCESSING",
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"PARTIAL_FAILED",
|
||||
"ABORTED",
|
||||
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PROCESSING: "The batch is being processed and runs are being created.",
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
|
||||
ABORTED: "The batch was aborted because child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "PARTIAL_FAILED":
|
||||
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "text-blue-500";
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
case "PARTIAL_FAILED":
|
||||
return "text-warning";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "Processing";
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "PARTIAL_FAILED":
|
||||
return "Partial failure";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
default: {
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -62,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -72,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -81,6 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
@@ -293,16 +305,20 @@ export function TaskRunsTable({
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tableStateParam) {
|
||||
searchParams.set("tableState", tableStateParam);
|
||||
}
|
||||
const path = v3RunSpanPath(organization, project, run.environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
}, searchParams);
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.friendlyId)}
|
||||
onChange={(element) => {
|
||||
onChange={() => {
|
||||
toggle(run.friendlyId);
|
||||
}}
|
||||
ref={(r) => {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
createReadableStreamFromReadable,
|
||||
type DataFunctionArgs,
|
||||
type EntryContext,
|
||||
} from "@remix-run/node"; // or cloudflare/deno
|
||||
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { parseAcceptLanguage } from "intl-parse-accept-language";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { PassThrough } from "stream";
|
||||
import * as Worker from "~/services/worker.server";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
|
||||
import {
|
||||
OperatingSystemContextProvider,
|
||||
OperatingSystemPlatform,
|
||||
} from "./components/primitives/OperatingSystemProvider";
|
||||
import { Prisma } from "./db.server";
|
||||
import { env } from "./env.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import {
|
||||
registerRunEngineEventBusHandlers,
|
||||
setupBatchQueueCallbacks,
|
||||
} from "./v3/runEngineHandlers.server";
|
||||
|
||||
const ABORT_DELAY = 30000;
|
||||
|
||||
@@ -228,19 +234,13 @@ process.on("uncaughtException", (error, origin) => {
|
||||
});
|
||||
|
||||
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
|
||||
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
|
||||
|
||||
export { apiRateLimiter } from "./services/apiRateLimit.server";
|
||||
export { engineRateLimiter } from "./services/engineRateLimit.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
export { socketIo } from "./v3/handleSocketIo.server";
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
|
||||
@@ -94,6 +94,8 @@ const EnvironmentSchema = z
|
||||
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
@@ -526,6 +528,7 @@ const EnvironmentSchema = z
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
@@ -535,6 +538,14 @@ const EnvironmentSchema = z
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
// 2-phase batch API settings
|
||||
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
|
||||
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
|
||||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
REALTIME_STREAM_TTL: z.coerce
|
||||
@@ -600,6 +611,12 @@ const EnvironmentSchema = z
|
||||
.default(60_000),
|
||||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
|
||||
|
||||
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
|
||||
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 60), // 1 hour
|
||||
|
||||
RUN_ENGINE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -929,6 +946,15 @@ const EnvironmentSchema = z
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
// BatchQueue DRR settings (Run Engine v2)
|
||||
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
|
||||
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
|
||||
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
|
||||
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
|
||||
// Global rate limit: max items processed per second across all consumers
|
||||
// If not set, no global rate limiting is applied
|
||||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||||
|
||||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ WHERE
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING";
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
environmentId: string;
|
||||
batchId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
|
||||
|
||||
export class BatchPresenter extends BasePresenter {
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
const isV2 = batch.batchVersion === "runengine:v2";
|
||||
|
||||
// For v2 batches in PROCESSING state, get live progress from Redis
|
||||
// This provides real-time updates without waiting for the batch to complete
|
||||
let liveSuccessCount = batch.successfulRunCount ?? 0;
|
||||
let liveFailureCount = batch.failedRunCount ?? 0;
|
||||
|
||||
if (isV2 && batch.status === "PROCESSING") {
|
||||
const liveProgress = await engine.getBatchQueueProgress(batch.id);
|
||||
if (liveProgress) {
|
||||
liveSuccessCount = liveProgress.successCount;
|
||||
liveFailureCount = liveProgress.failureCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
status: batch.status as BatchTaskRunStatus,
|
||||
runCount: batch.runCount,
|
||||
batchVersion: batch.batchVersion,
|
||||
isV2,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
completedAt: batch.completedAt?.toISOString(),
|
||||
processingStartedAt: batch.processingStartedAt?.toISOString(),
|
||||
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
|
||||
finishedAt: batch.completedAt
|
||||
? batch.completedAt.toISOString()
|
||||
: hasFinished
|
||||
? batch.updatedAt.toISOString()
|
||||
: undefined,
|
||||
hasFinished,
|
||||
successfulRunCount: liveSuccessCount,
|
||||
failedRunCount: liveFailureCount,
|
||||
idempotencyKey: batch.idempotencyKey,
|
||||
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
|
||||
errors: batch.errors.map((error) => ({
|
||||
id: error.id,
|
||||
index: error.index,
|
||||
taskIdentifier: error.taskIdentifier,
|
||||
error: error.error,
|
||||
errorCode: error.errorCode,
|
||||
createdAt: error.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
@@ -154,17 +155,23 @@ export class DeploymentPresenter {
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
const buildServerMetadata = deployment.buildServerMetadata
|
||||
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
||||
: undefined;
|
||||
|
||||
let eventStream = undefined;
|
||||
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { BranchTrackingConfigSchema } from "~/v3/github";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type GitHubSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export class GitHubSettingsPresenter extends BasePresenter {
|
||||
public call({ projectId, organizationId }: GitHubSettingsOptions) {
|
||||
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
|
||||
|
||||
if (!githubAppEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
connectedRepository: undefined,
|
||||
installations: undefined,
|
||||
isPreviewEnvironmentEnabled: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const findConnectedGithubRepository = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
branchTracking: true,
|
||||
previewDeploymentsEnabled: true,
|
||||
createdAt: true,
|
||||
repository: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((connectedGithubRepository) => {
|
||||
if (!connectedGithubRepository) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
|
||||
connectedGithubRepository.branchTracking
|
||||
);
|
||||
const branchTracking = branchTrackingOrFailure.success
|
||||
? branchTrackingOrFailure.data
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...connectedGithubRepository,
|
||||
branchTracking,
|
||||
};
|
||||
});
|
||||
|
||||
const listGithubAppInstallations = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).githubAppInstallation.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountHandle: true,
|
||||
targetType: true,
|
||||
appInstallationId: true,
|
||||
repositories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const isPreviewEnvironmentEnabled = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: "preview",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((previewEnvironment) => previewEnvironment !== null);
|
||||
|
||||
return ResultAsync.combine([
|
||||
isPreviewEnvironmentEnabled(),
|
||||
findConnectedGithubRepository(),
|
||||
listGithubAppInstallations(),
|
||||
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
|
||||
enabled: true,
|
||||
connectedRepository: connectedGithubRepository,
|
||||
installations: githubAppInstallations,
|
||||
isPreviewEnvironmentEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
environmentId: run.runtimeEnvironment.id,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
|
||||
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
|
||||
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
|
||||
queue: {
|
||||
name: run.queue,
|
||||
@@ -357,6 +358,8 @@ export class SpanPresenter extends BasePresenter {
|
||||
//idempotency
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
//debounce
|
||||
debounce: true,
|
||||
//delayed
|
||||
delayUntil: true,
|
||||
//ttl
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { motion } from "framer-motion";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const BatchParamSchema = EnvironmentParamSchema.extend({
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam, batchParam } =
|
||||
BatchParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BatchPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
batchId: batchParam,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ batch: data });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batch } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-reload when batch is still in progress
|
||||
useAutoRevalidate({
|
||||
interval: 1000,
|
||||
onFocus: true,
|
||||
disabled: batch.hasFinished,
|
||||
});
|
||||
|
||||
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
{/* Header */}
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{descriptionForBatchStatus(batch.status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="space-y-3">
|
||||
{/* Progress meter for v2 batches */}
|
||||
{showProgressMeter && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchProgressMeter
|
||||
successCount={batch.successfulRunCount}
|
||||
failureCount={batch.failedRunCount}
|
||||
totalCount={batch.runCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="px-3 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total runs</Property.Label>
|
||||
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.isV2 && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Successfully created</Property.Label>
|
||||
<Property.Value className="text-success">
|
||||
{formatNumber(batch.successfulRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.failedRunCount > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Failed to create</Property.Label>
|
||||
<Property.Value className="text-error">
|
||||
{formatNumber(batch.failedRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{batch.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.processingStartedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingStartedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{batch.processingCompletedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing completed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingCompletedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Finished</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Errors section */}
|
||||
{batch.errors.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
Run creation errors ({batch.errors.length})
|
||||
</Header3>
|
||||
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
|
||||
{batch.errors.map((error) => (
|
||||
<div key={error.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
Item #{error.index}
|
||||
</span>
|
||||
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
|
||||
</div>
|
||||
{error.errorCode && (
|
||||
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
|
||||
{error.errorCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1 text-error">
|
||||
{error.error}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={v3BatchRunsPath(organization, project, environment, batch)}
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchProgressMeterProps = {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
|
||||
const processedCount = successCount + failureCount;
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Run creation progress</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(processedCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full bg-error"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
|
||||
</div>
|
||||
{failureCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-error" />
|
||||
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-81
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type BatchList,
|
||||
type BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { batchParam } = useParams();
|
||||
const isShowingInspector = batchParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -123,22 +124,34 @@ export default function Page() {
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="batches-main" min={"100px"}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{isShowingInspector && (
|
||||
<>
|
||||
<ResizableHandle id="batches-handle" />
|
||||
<ResizablePanel id="batches-inspector" min="100px" default="500px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
@@ -147,10 +160,14 @@ export default function Page() {
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { batchParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, environment, batch);
|
||||
batches.map((batch) => {
|
||||
const basePath = v3BatchPath(organization, project, environment, batch);
|
||||
const inspectorPath = `${basePath}${location.search}`;
|
||||
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
|
||||
const isSelected = batchParam === batch.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<TableCell to={inspectorPath} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
|
||||
<TableCell
|
||||
to={inspectorPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
<BatchActionsCell runsPath={runsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
function BatchActionsCell({ runsPath }: { runsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
hiddenButtons={
|
||||
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
|
||||
View runs
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+2
-2
@@ -359,11 +359,11 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
|
||||
+289
-21
@@ -2,6 +2,7 @@ import {
|
||||
ArrowUturnLeftIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -68,7 +69,6 @@ import {
|
||||
eventBorderClassName,
|
||||
} from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
v3RunStreamingPath,
|
||||
@@ -98,6 +99,13 @@ import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectP
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -131,6 +139,103 @@ const resizableSettings = {
|
||||
|
||||
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
|
||||
|
||||
type RunsListNavigation = {
|
||||
runs: Array<{ friendlyId: string; spanId: string }>;
|
||||
pagination: { next?: string; previous?: string };
|
||||
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
};
|
||||
|
||||
async function getRunsListFromTableState({
|
||||
tableStateParam,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
}: {
|
||||
tableStateParam: string | null;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
runParam: string;
|
||||
userId: string;
|
||||
}): Promise<RunsListNavigation | null> {
|
||||
if (!tableStateParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
|
||||
|
||||
if (!project || !environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
|
||||
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
pageSize: 25, // Load enough runs to provide navigation context
|
||||
});
|
||||
|
||||
const runsList: RunsListNavigation = {
|
||||
runs: currentPageResult.runs,
|
||||
pagination: currentPageResult.pagination,
|
||||
};
|
||||
|
||||
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
|
||||
|
||||
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
|
||||
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
direction: "backward",
|
||||
pageSize: 1, // We only need the last run from the previous page
|
||||
});
|
||||
|
||||
if (prevPageResult.runs.length > 0) {
|
||||
runsList.prevPageLastRun = {
|
||||
friendlyId: prevPageResult.runs[0].friendlyId,
|
||||
spanId: prevPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
|
||||
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
direction: "forward",
|
||||
pageSize: 1, // We only need the first run from the next page
|
||||
});
|
||||
|
||||
if (nextPageResult.runs.length > 0) {
|
||||
runsList.nextPageFirstRun = {
|
||||
friendlyId: nextPageResult.runs[0].friendlyId,
|
||||
spanId: nextPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return runsList;
|
||||
} catch (error) {
|
||||
logger.error("Error loading runs list from tableState:", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -169,6 +274,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
|
||||
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
|
||||
|
||||
const runsList = await getRunsListFromTableState({
|
||||
tableStateParam: url.searchParams.get("tableState"),
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
run: result.run,
|
||||
trace: result.trace,
|
||||
@@ -177,13 +291,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
parent,
|
||||
tree,
|
||||
},
|
||||
runsList,
|
||||
});
|
||||
};
|
||||
|
||||
type LoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function Page() {
|
||||
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
|
||||
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -191,16 +306,30 @@ export default function Page() {
|
||||
logCount: trace?.events.length ?? 0,
|
||||
isCompleted: run.completedAt !== null,
|
||||
});
|
||||
const { value } = useSearchParams();
|
||||
const tableState = decodeURIComponent(value("tableState") ?? "");
|
||||
const tableStateSearchParams = new URLSearchParams(tableState);
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
const tabParam = value("tab") ?? undefined;
|
||||
const spanParam = value("span") ?? undefined;
|
||||
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: v3RunsPath(organization, project, environment),
|
||||
to: v3RunsPath(organization, project, environment, filters),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={<CopyableText value={run.friendlyId} />}
|
||||
title={<>
|
||||
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
|
||||
{tableState && (<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>)}
|
||||
</>}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
|
||||
<PageAccessories>
|
||||
@@ -276,14 +405,10 @@ export default function Page() {
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
) : (
|
||||
<NoLogsView
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
)}
|
||||
</PageBody>
|
||||
@@ -291,7 +416,7 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -385,7 +510,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
);
|
||||
}
|
||||
|
||||
function NoLogsView({ run, resizable }: LoaderData) {
|
||||
function NoLogsView({ run }: Pick<LoaderData, "run">) {
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -819,7 +944,6 @@ function TimelineView({
|
||||
scale,
|
||||
rootSpanStatus,
|
||||
rootStartedAt,
|
||||
parentRef,
|
||||
timelineScrollRef,
|
||||
virtualizer,
|
||||
events,
|
||||
@@ -835,6 +959,7 @@ function TimelineView({
|
||||
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
|
||||
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
|
||||
const maxTimelineWidth = minTimelineWidth * 10;
|
||||
const disableSpansAnimations = rootSpanStatus !== "executing";
|
||||
|
||||
//we want to live-update the duration if the root span is still executing
|
||||
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
|
||||
@@ -1006,7 +1131,8 @@ function TimelineView({
|
||||
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1024,7 +1150,8 @@ function TimelineView({
|
||||
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
|
||||
eventBorderClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1043,7 +1170,8 @@ function TimelineView({
|
||||
>
|
||||
<motion.div
|
||||
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
|
||||
layoutId={`mark-${node.id}`}
|
||||
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
</Timeline.Span>
|
||||
) : null}
|
||||
@@ -1066,6 +1194,7 @@ function TimelineView({
|
||||
}
|
||||
node={node}
|
||||
fadeLeft={isTopSpan && queuedDuration !== undefined}
|
||||
disableAnimations={disableSpansAnimations}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1080,7 +1209,8 @@ function TimelineView({
|
||||
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.id}
|
||||
layoutId={disableSpansAnimations ? undefined : node.id}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1312,8 +1442,9 @@ function SpanWithDuration({
|
||||
showDuration,
|
||||
node,
|
||||
fadeLeft,
|
||||
disableAnimations,
|
||||
...props
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean; disableAnimations?: boolean }) {
|
||||
return (
|
||||
<Timeline.Span {...props}>
|
||||
<motion.div
|
||||
@@ -1323,7 +1454,8 @@ function SpanWithDuration({
|
||||
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
|
||||
)}
|
||||
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
|
||||
layoutId={node.id}
|
||||
layoutId={disableAnimations ? undefined : node.id}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{node.data.isPartial && (
|
||||
<div
|
||||
@@ -1336,10 +1468,12 @@ function SpanWithDuration({
|
||||
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
|
||||
!showDuration && "opacity-0"
|
||||
)}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
|
||||
layout="position"
|
||||
layout={disableAnimations ? undefined : "position"}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{formatDurationMilliseconds(props.durationMs, {
|
||||
style: "short",
|
||||
@@ -1422,16 +1556,16 @@ function KeyboardShortcuts({
|
||||
expandAllBelowDepth,
|
||||
collapseAllBelowDepth,
|
||||
toggleExpandLevel,
|
||||
setShowDurations,
|
||||
}: {
|
||||
expandAllBelowDepth: (depth: number) => void;
|
||||
collapseAllBelowDepth: (depth: number) => void;
|
||||
toggleExpandLevel: (depth: number) => void;
|
||||
setShowDurations: (show: (show: boolean) => boolean) => void;
|
||||
setShowDurations?: (show: (show: boolean) => boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ArrowKeyShortcuts />
|
||||
<AdjacentRunsShortcuts />
|
||||
<ShortcutWithAction
|
||||
shortcut={{ key: "e" }}
|
||||
action={() => expandAllBelowDepth(0)}
|
||||
@@ -1448,6 +1582,16 @@ function KeyboardShortcuts({
|
||||
);
|
||||
}
|
||||
|
||||
function AdjacentRunsShortcuts() {
|
||||
return (<div className="flex items-center gap-0.5">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Adjacent runs
|
||||
</Paragraph>
|
||||
</div>);
|
||||
}
|
||||
|
||||
function ArrowKeyShortcuts() {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -1494,7 +1638,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
|
||||
<span className="text-[0.75rem] text-text-dimmed">–</span>
|
||||
<span className="text-[0.65rem] text-text-dimmed">–</span>
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Toggle level
|
||||
@@ -1526,3 +1670,127 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
tableState: string;
|
||||
run: { friendlyId: string, spanId: string };
|
||||
runsList: RunsListNavigation | null;
|
||||
tabParam?: string;
|
||||
useSpan?: boolean;
|
||||
}): [string | null, string | null] {
|
||||
if (!runsList || runsList.runs.length === 0) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
|
||||
let previousRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const previousRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex > 0) {
|
||||
previousRun = runsList.runs[currentIndex - 1];
|
||||
} else if (runsList.prevPageLastRun) {
|
||||
previousRun = runsList.prevPageLastRun;
|
||||
// Update tableState with the new cursor for the previous page
|
||||
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
|
||||
previousRunTableState.set("direction", "backward");
|
||||
}
|
||||
|
||||
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
|
||||
let nextRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const nextRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex < runsList.runs.length - 1) {
|
||||
nextRun = runsList.runs[currentIndex + 1];
|
||||
} else if (runsList.nextPageFirstRun) {
|
||||
nextRun = runsList.nextPageFirstRun;
|
||||
// Update tableState with the new cursor for the next page
|
||||
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
|
||||
nextRunTableState.set("direction", "forward");
|
||||
}
|
||||
|
||||
const previousURLSearchParams = new URLSearchParams();
|
||||
previousURLSearchParams.set("tableState", previousRunTableState.toString());
|
||||
if (previousRun && useSpan) {
|
||||
previousURLSearchParams.set("span", previousRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
previousURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const previousRunPath = previousRun
|
||||
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
|
||||
: null;
|
||||
|
||||
const nextURLSearchParams = new URLSearchParams();
|
||||
nextURLSearchParams.set("tableState", nextRunTableState.toString());
|
||||
if (nextRun && useSpan) {
|
||||
nextURLSearchParams.set("span", nextRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
nextURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const nextRunPath = nextRun
|
||||
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
|
||||
: null;
|
||||
|
||||
return [previousRunPath, nextRunPath];
|
||||
}
|
||||
|
||||
|
||||
function PreviousRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronUpIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "[" }}
|
||||
tooltip="Previous Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon={ChevronDownIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "]" }}
|
||||
tooltip="Next Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -55,6 +55,7 @@ import {
|
||||
v3CreateBulkActionPath,
|
||||
v3ProjectPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
@@ -235,7 +236,13 @@ function RunsList({
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
<RunTaskInstructions
|
||||
task={
|
||||
list.filters.tasks.length === 1
|
||||
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
|
||||
@@ -291,6 +298,7 @@ function RunsList({
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions() {
|
||||
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={
|
||||
task
|
||||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
|
||||
: v3TestPath(organization, project, environment)
|
||||
}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-lime-500"
|
||||
|
||||
+1
@@ -318,6 +318,7 @@ export default function Page() {
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
|
||||
+14
-666
@@ -1,35 +1,18 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
@@ -55,32 +38,12 @@ import {
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
organizationPath,
|
||||
v3ProjectPath,
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -128,29 +91,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
githubAppInstallations: gitHubApp.installations,
|
||||
connectedGithubRepository: gitHubApp.connectedRepository,
|
||||
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
|
||||
buildSettings,
|
||||
});
|
||||
};
|
||||
|
||||
const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
});
|
||||
|
||||
const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
@@ -220,12 +164,7 @@ export function createSchema(
|
||||
}
|
||||
}),
|
||||
}),
|
||||
ConnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
UpdateBuildSettingsFormSchema,
|
||||
z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
@@ -316,101 +255,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "disconnect-repo": {
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
|
||||
}
|
||||
case "update-git-settings": {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "github_app_not_enabled": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
|
||||
}
|
||||
case "connected_gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
|
||||
}
|
||||
case "production_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
|
||||
}
|
||||
case "staging_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update Git settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
|
||||
}
|
||||
case "connect-repo": {
|
||||
const { repositoryId, installationId } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub repository not found");
|
||||
}
|
||||
case "project_already_has_connected_repository": {
|
||||
return redirectBackWithErrorMessage(
|
||||
request,
|
||||
"Project already has a connected repository"
|
||||
);
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to connect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
...submission,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
case "update-build-settings": {
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
@@ -446,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
githubAppInstallations,
|
||||
connectedGithubRepository,
|
||||
githubAppEnabled,
|
||||
buildSettings,
|
||||
isPreviewEnvironmentEnabled,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
@@ -578,19 +416,12 @@ export default function Page() {
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
{connectedGithubRepository ? (
|
||||
<ConnectedGitHubRepoForm
|
||||
connectedGitHubRepo={connectedGithubRepository}
|
||||
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
|
||||
/>
|
||||
) : (
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={githubAppInstallations ?? []}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
/>
|
||||
)}
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -650,489 +481,6 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
open?: boolean;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.get("openGithubRepoModal") === "1") {
|
||||
setIsModalOpen(true);
|
||||
params.delete("openGithubRepoModal");
|
||||
setSearchParams(params);
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" {...form.props} className="w-full">
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
previewEnvironmentEnabled,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
previewEnvironmentEnabled?: boolean;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
const organization = useOrganization();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" {...gitSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every push to the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
disabled={!previewEnvironmentEnabled}
|
||||
defaultChecked={
|
||||
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
||||
}
|
||||
variant="small"
|
||||
label="Create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!previewEnvironmentEnabled && (
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
|
||||
enable preview branches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
@@ -1248,7 +596,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.1.3 or newer is required.
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ export default function Page() {
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -35,6 +38,18 @@ export const loader = createLoaderApiRoute(
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
// Include error details for PARTIAL_FAILED batches
|
||||
successfulRunCount: batch.successfulRunCount ?? undefined,
|
||||
failedRunCount: batch.failedRunCount ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: BodySchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: () => ({}),
|
||||
superScopes: ["write:runs", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, body, authentication }) => {
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
try {
|
||||
const result = await service.call(
|
||||
params.key,
|
||||
body.taskIdentifier,
|
||||
authentication.environment
|
||||
);
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 400 });
|
||||
}
|
||||
|
||||
logger.error("Failed to reset idempotency key via API", {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -33,8 +36,21 @@ export const loader = createLoaderApiRoute(
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
processingCompletedAt: batch.processingCompletedAt ?? undefined,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
processing: {
|
||||
completedAt: batch.processingCompletedAt ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ const { action, loader } = createActionApiRoute(
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
// Note: SDK v4.3+ uses the 2-phase batch API (POST /api/v3/batches + streaming items)
|
||||
// This endpoint is for backwards compatibility with older SDK versions
|
||||
const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined);
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
StreamBatchItemsService,
|
||||
createNdjsonParserStream,
|
||||
streamToAsyncIterable,
|
||||
} from "~/runEngine/services/streamBatchItems.server";
|
||||
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 2 of 2-phase batch API: Stream batch items.
|
||||
*
|
||||
* POST /api/v3/batches/:batchId/items
|
||||
*
|
||||
* Accepts an NDJSON stream of batch items and enqueues them to the BatchQueue.
|
||||
* Each line in the body should be a valid BatchItemNDJSON object.
|
||||
*
|
||||
* The stream is processed with backpressure - items are enqueued as they arrive.
|
||||
* The batch is sealed when the stream completes successfully.
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Validate params
|
||||
const paramsResult = ParamsSchema.safeParse(params);
|
||||
if (!paramsResult.success) {
|
||||
return json({ error: "Invalid batch ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { batchId } = paramsResult.data;
|
||||
|
||||
// Validate content type
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (
|
||||
!contentType.includes("application/x-ndjson") &&
|
||||
!contentType.includes("application/ndjson")
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error: "Content-Type must be application/x-ndjson or application/ndjson",
|
||||
},
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authResult = await authenticateApiRequestWithFailure(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the request body stream
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return json({ error: "Request body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Stream batch items request", {
|
||||
batchId,
|
||||
contentType,
|
||||
envId: authResult.environment.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create NDJSON parser transform stream
|
||||
const parser = createNdjsonParserStream(env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE);
|
||||
|
||||
// Pipe the request body through the parser
|
||||
const parsedStream = body.pipeThrough(parser);
|
||||
|
||||
// Convert to async iterable for the service
|
||||
const itemsIterator = streamToAsyncIterable(parsedStream);
|
||||
|
||||
// Process the stream
|
||||
const service = new StreamBatchItemsService();
|
||||
const result = await service.call(authResult.environment, batchId, itemsIterator, {
|
||||
maxItemBytes: env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
|
||||
});
|
||||
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("Stream batch items error", {
|
||||
batchId,
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
// Check for stream parsing errors
|
||||
if (
|
||||
error.message.includes("Invalid JSON") ||
|
||||
error.message.includes("exceeds maximum size")
|
||||
) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Return 405 for GET requests - only POST is allowed
|
||||
return json(
|
||||
{
|
||||
error: "Method not allowed. Use POST to stream batch items.",
|
||||
},
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateBatchRequestBody, CreateBatchResponse, generateJWT } from "@trigger.dev/core/v3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server";
|
||||
import { CreateBatchService } from "~/runEngine/services/createBatch.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
handleRequestIdempotency,
|
||||
saveRequestIdempotency,
|
||||
} from "~/utils/requestIdempotency.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
/**
|
||||
* Phase 1 of 2-phase batch API: Create a batch.
|
||||
*
|
||||
* POST /api/v3/batches
|
||||
*
|
||||
* Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
|
||||
* Items are streamed separately via POST /api/v3/batches/:batchId/items
|
||||
*/
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
body: CreateBatchRequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
|
||||
authorization: {
|
||||
action: "batchTrigger",
|
||||
resource: () => ({
|
||||
// No specific tasks to authorize at batch creation time
|
||||
// Tasks are validated when items are streamed
|
||||
tasks: [],
|
||||
}),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, headers, authentication }) => {
|
||||
// Validate runCount
|
||||
if (body.runCount <= 0) {
|
||||
return json({ error: "runCount must be a positive integer" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check runCount against limit
|
||||
if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) {
|
||||
return json(
|
||||
{
|
||||
error: `Batch runCount of ${body.runCount} exceeds maximum allowed of ${env.STREAMING_BATCH_MAX_ITEMS}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Create batch request", {
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
triggerVersion,
|
||||
isFromWorker,
|
||||
triggerClient,
|
||||
});
|
||||
|
||||
// Handle idempotency for the batch creation
|
||||
const cachedResponse = await handleRequestIdempotency<
|
||||
{ friendlyId: string; runCount: number },
|
||||
CreateBatchResponse
|
||||
>(body.idempotencyKey, {
|
||||
requestType: "create-batch",
|
||||
findCachedEntity: async (cachedRequestId) => {
|
||||
return await prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: cachedRequestId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
runCount: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
buildResponse: (cachedBatch) => ({
|
||||
id: cachedBatch.friendlyId,
|
||||
runCount: cachedBatch.runCount,
|
||||
isCached: true,
|
||||
}),
|
||||
buildResponseHeaders: async (responseBody) => {
|
||||
return await responseHeaders(responseBody, authentication.environment, triggerClient);
|
||||
},
|
||||
});
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const traceContext = isFromWorker
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
const service = new CreateBatchService();
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
|
||||
});
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
realtimeStreamsVersion: determineRealtimeStreamsVersion(
|
||||
realtimeStreamsVersion ?? undefined
|
||||
),
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(
|
||||
batch,
|
||||
authentication.environment,
|
||||
triggerClient
|
||||
);
|
||||
|
||||
return json(batch, {
|
||||
status: 202,
|
||||
headers: $responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BatchRateLimitExceededError) {
|
||||
logger.info("Batch rate limit exceeded", {
|
||||
limit: error.limit,
|
||||
remaining: error.remaining,
|
||||
resetAt: error.resetAt.toISOString(),
|
||||
itemCount: error.itemCount,
|
||||
});
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": error.limit.toString(),
|
||||
"X-RateLimit-Remaining": error.remaining.toString(),
|
||||
"X-RateLimit-Reset": Math.floor(error.resetAt.getTime() / 1000).toString(),
|
||||
"Retry-After": Math.max(
|
||||
1,
|
||||
Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Create batch error", {
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof OutOfEntitlementError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function responseHeaders(
|
||||
batch: CreateBatchResponse,
|
||||
environment: AuthenticatedEnvironment,
|
||||
triggerClient?: string | null
|
||||
): Promise<Record<string, string>> {
|
||||
const claimsHeader = JSON.stringify({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`],
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
};
|
||||
}
|
||||
|
||||
export { action, loader };
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { redirectCookie } from "./auth.github";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
@@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { env } from "~/env.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = () => redirect("/login");
|
||||
|
||||
export let action: ActionFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
|
||||
|
||||
try {
|
||||
// call authenticate as usual, in successRedirect use returnTo or a fallback
|
||||
return await authenticator.authenticate("github", request, {
|
||||
successRedirect: redirectTo ?? "/",
|
||||
successRedirect: safeRedirect,
|
||||
failureRedirect: "/login",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -19,8 +22,8 @@ export let action: ActionFunction = async ({ request }) => {
|
||||
// if the error is a Response and is a redirect
|
||||
if (error instanceof Response) {
|
||||
// we need to append a Set-Cookie header with a cookie storing the
|
||||
// returnTo value
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(redirectTo));
|
||||
// returnTo value (store the sanitized path)
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -29,4 +32,6 @@ export let action: ActionFunction = async ({ request }) => {
|
||||
export const redirectCookie = createCookie("redirect-to", {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession } from "~/services/sessionStorage.server";
|
||||
import { redirectCookie } from "./auth.google";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = async ({ request }) => {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const redirectValue = await redirectCookie.parse(cookie);
|
||||
const redirectTo = sanitizeRedirectPath(redirectValue);
|
||||
|
||||
const auth = await authenticator.authenticate("google", request, {
|
||||
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
|
||||
});
|
||||
|
||||
// manually get the session
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
const userRecord = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: auth.userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
mfaEnabledAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userRecord) {
|
||||
return redirectWithErrorMessage(
|
||||
"/login",
|
||||
request,
|
||||
"Could not find your account. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
if (userRecord.mfaEnabledAt) {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
|
||||
|
||||
return redirect(redirectTo, { headers });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { env } from "~/env.server";
|
||||
import { sanitizeRedirectPath } from "~/utils";
|
||||
|
||||
export let loader: LoaderFunction = () => redirect("/login");
|
||||
|
||||
export let action: ActionFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
|
||||
|
||||
try {
|
||||
// call authenticate as usual, in successRedirect use returnTo or a fallback
|
||||
return await authenticator.authenticate("google", request, {
|
||||
successRedirect: safeRedirect,
|
||||
failureRedirect: "/login",
|
||||
});
|
||||
} catch (error) {
|
||||
// here we catch anything authenticator.authenticate throw, this will
|
||||
// include redirects
|
||||
// if the error is a Response and is a redirect
|
||||
if (error instanceof Response) {
|
||||
// we need to append a Set-Cookie header with a cookie storing the
|
||||
// returnTo value (store the sanitized path)
|
||||
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const redirectCookie = createCookie("google-redirect-to", {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
@@ -10,12 +12,33 @@ import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { isGithubAuthSupported } from "~/services/auth.server";
|
||||
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
|
||||
import { getLastAuthMethod } from "~/services/lastAuthMethod.server";
|
||||
import { commitSession, setRedirectTo } from "~/services/redirectTo.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { getUserSession } from "~/services/sessionStorage.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
|
||||
function LastUsedBadge() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className="absolute -right-5 top-1 z-10 -translate-y-1/2 shadow-md md:-right-[4.6rem] md:top-1/2">
|
||||
<motion.div
|
||||
className="relative rounded border border-charcoal-700 bg-charcoal-800 px-2 py-1 text-center text-xxs font-medium uppercase text-blue-500"
|
||||
initial={shouldReduceMotion ? undefined : { opacity: 0, x: 4 }}
|
||||
animate={shouldReduceMotion ? undefined : { opacity: 1, x: 0 }}
|
||||
transition={shouldReduceMotion ? undefined : { duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<span className="hidden h-2 w-2 rotate-45 border-b border-l border-charcoal-700 bg-charcoal-800 md:block" />
|
||||
</span>
|
||||
Last used
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = ({ matches }) => {
|
||||
const parentMeta = matches
|
||||
.flatMap((match) => match.meta ?? [])
|
||||
@@ -45,6 +68,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const url = requestUrl(request);
|
||||
const redirectTo = url.searchParams.get("redirectTo");
|
||||
const lastAuthMethod = await getLastAuthMethod(request);
|
||||
|
||||
if (redirectTo) {
|
||||
const session = await setRedirectTo(request, redirectTo);
|
||||
@@ -53,6 +77,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
{
|
||||
redirectTo,
|
||||
showGithubAuth: isGithubAuthSupported,
|
||||
showGoogleAuth: isGoogleAuthSupported,
|
||||
lastAuthMethod,
|
||||
authError: null,
|
||||
},
|
||||
{
|
||||
@@ -77,6 +103,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return typedjson({
|
||||
redirectTo: null,
|
||||
showGithubAuth: isGithubAuthSupported,
|
||||
showGoogleAuth: isGoogleAuthSupported,
|
||||
lastAuthMethod,
|
||||
authError,
|
||||
});
|
||||
}
|
||||
@@ -87,31 +115,57 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
{data.showGithubAuth && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<Header1 className="pb-4 font-semibold sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
{data.showGithubAuth && (
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "github" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<GitHubLightIcon className={"mr-2 size-5"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
>
|
||||
<GitHubLightIcon className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
{data.showGoogleAuth && (
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "google" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with google"
|
||||
>
|
||||
<GoogleLogo className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with Google</span>
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "email" && <LastUsedBadge />}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
variant="secondary/extra-large"
|
||||
@@ -122,22 +176,22 @@ export default function LoginPage() {
|
||||
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
|
||||
Continue with Email
|
||||
</LinkButton>
|
||||
{data.authError && <FormError>{data.authError}</FormError>}
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>
|
||||
{" "}and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>
|
||||
{" "}policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Form>
|
||||
{data.authError && <FormError>{data.authError}</FormError>}
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>{" "}
|
||||
and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>{" "}
|
||||
policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
|
||||
import { getRedirectTo } from "~/services/redirectTo.server";
|
||||
import { commitSession, getSession } from "~/services/sessionStorage.server";
|
||||
|
||||
@@ -38,19 +39,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
session.set("pending-mfa-user-id", userRecord.id);
|
||||
session.set("pending-mfa-redirect-to", redirectTo ?? "/");
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
|
||||
|
||||
return redirect("/login/mfa", { headers });
|
||||
}
|
||||
|
||||
// and store the user data
|
||||
session.set(authenticator.sessionKey, auth);
|
||||
|
||||
return redirect(redirectTo ?? "/", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", await setLastAuthMethodHeader("email"));
|
||||
|
||||
return redirect(redirectTo ?? "/", { headers });
|
||||
}
|
||||
|
||||
+877
@@ -0,0 +1,877 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { CheckCircleIcon, LockClosedIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation, useNavigate, useSearchParams, useLocation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import { GitHubSettingsPresenter } from "~/presenters/v3/GitHubSettingsPresenter.server";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
export type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
export type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const DisconnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
redirectUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitHubActionSchema = z.discriminatedUnion("action", [
|
||||
ConnectGitHubRepoFormSchema,
|
||||
DisconnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// Loader
|
||||
// ============================================================================
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new GitHubSettingsPresenter();
|
||||
const resultOrFail = await presenter.call({
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
throw new Response("Failed to load GitHub settings", { status: 500 });
|
||||
}
|
||||
|
||||
return typedjson(resultOrFail.value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action
|
||||
// ============================================================================
|
||||
|
||||
function redirectWithMessage(
|
||||
request: Request,
|
||||
redirectUrl: string | undefined,
|
||||
message: string,
|
||||
type: "success" | "error"
|
||||
) {
|
||||
if (type === "success") {
|
||||
return redirectUrl
|
||||
? redirectWithSuccessMessage(redirectUrl, request, message)
|
||||
: redirectBackWithSuccessMessage(request, message);
|
||||
}
|
||||
return redirectUrl
|
||||
? redirectWithErrorMessage(redirectUrl, request, message)
|
||||
: redirectBackWithErrorMessage(request, message);
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: GitHubActionSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const projectSettingsService = new ProjectSettingsService();
|
||||
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
userId
|
||||
);
|
||||
|
||||
if (membershipResultOrFail.isErr()) {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { action: actionType } = submission.value;
|
||||
|
||||
// Handle connect-repo action
|
||||
if (actionType === "connect-repo") {
|
||||
const { repositoryId, installationId, redirectUrl } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"GitHub repository connected successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
const errorType = resultOrFail.error.type;
|
||||
|
||||
if (errorType === "gh_repository_not_found") {
|
||||
return redirectWithMessage(request, redirectUrl, "GitHub repository not found", "error");
|
||||
}
|
||||
|
||||
if (errorType === "project_already_has_connected_repository") {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Project already has a connected repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Failed to connect GitHub repository", { error: resultOrFail.error });
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Failed to connect GitHub repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
// Handle disconnect-repo action
|
||||
if (actionType === "disconnect-repo") {
|
||||
const { redirectUrl } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"GitHub repository disconnected successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", { error: resultOrFail.error });
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Failed to disconnect GitHub repository",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
// Handle update-git-settings action
|
||||
if (actionType === "update-git-settings") {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled, redirectUrl } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isOk()) {
|
||||
return redirectWithMessage(
|
||||
request,
|
||||
redirectUrl,
|
||||
"Git settings updated successfully",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
|
||||
const errorType = resultOrFail.error.type;
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
github_app_not_enabled: "GitHub app is not enabled",
|
||||
connected_gh_repository_not_found: "Connected GitHub repository not found",
|
||||
production_tracking_branch_not_found: "Production tracking branch not found",
|
||||
staging_tracking_branch_not_found: "Staging tracking branch not found",
|
||||
};
|
||||
|
||||
const message = errorMessages[errorType];
|
||||
if (message) {
|
||||
return redirectWithMessage(request, redirectUrl, message, "error");
|
||||
}
|
||||
|
||||
logger.error("Failed to update Git settings", { error: resultOrFail.error });
|
||||
return redirectWithMessage(request, redirectUrl, "Failed to update Git settings", "error");
|
||||
}
|
||||
|
||||
// Exhaustive check - this should never be reached
|
||||
submission.value satisfies never;
|
||||
return redirectBackWithErrorMessage(request, "Failed to process request");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper: Build resource URL for fetching GitHub data
|
||||
// ============================================================================
|
||||
|
||||
export function gitHubResourcePath(
|
||||
organizationSlug: string,
|
||||
projectSlug: string,
|
||||
environmentSlug: string
|
||||
) {
|
||||
return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/github`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
export function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
redirectUrl,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.get("openGithubRepoModal") === "1") {
|
||||
setIsModalOpen(true);
|
||||
params.delete("openGithubRepoModal");
|
||||
setSearchParams(params);
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" action={actionUrl} {...form.props} className="w-full">
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)}?openGithubRepoModal=1`
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
redirectUrl,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
|
||||
const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug });
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
`${githubInstallationRedirect}?openGithubRepoModal=1`
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
previewEnvironmentEnabled,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
billingPath,
|
||||
redirectUrl,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
previewEnvironmentEnabled?: boolean;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
billingPath: string;
|
||||
redirectUrl?: string;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post" action={actionUrl}>
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" action={actionUrl} {...gitSettingsForm.props}>
|
||||
{redirectUrl && <input type="hidden" name="redirectUrl" value={redirectUrl} />}
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every push to the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
disabled={!previewEnvironmentEnabled}
|
||||
defaultChecked={
|
||||
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
|
||||
}
|
||||
variant="small"
|
||||
label="Create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!previewEnvironmentEnabled && (
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
<TextLink to={billingPath}>Upgrade</TextLink> your plan to enable preview
|
||||
branches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main GitHub Settings Panel Component
|
||||
// ============================================================================
|
||||
|
||||
export function GitHubSettingsPanel({
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
billingPath,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
billingPath: string;
|
||||
}) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const location = useLocation();
|
||||
|
||||
// Use provided redirectUrl or fall back to current path (without search params)
|
||||
const effectiveRedirectUrl = location.pathname;
|
||||
useEffect(() => {
|
||||
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
|
||||
}, [organizationSlug, projectSlug, environmentSlug]);
|
||||
|
||||
const data = fetcher.data;
|
||||
|
||||
// Loading state
|
||||
if (fetcher.state === "loading" && !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-text-dimmed">
|
||||
<SpinnerWhite className="size-4" />
|
||||
<span className="text-sm">Loading GitHub settings...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub app not enabled
|
||||
if (!data || !data.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Connected repository exists - show form
|
||||
if (data.connectedRepository) {
|
||||
return (
|
||||
<ConnectedGitHubRepoForm
|
||||
connectedGitHubRepo={data.connectedRepository}
|
||||
previewEnvironmentEnabled={data.isPreviewEnvironmentEnabled}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
billingPath={billingPath}
|
||||
redirectUrl={effectiveRedirectUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No connected repository - show connection prompt
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={data.installations ?? []}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
redirectUrl={effectiveRedirectUrl}
|
||||
/>
|
||||
{!data.connectedRepository && (
|
||||
<Hint>
|
||||
Connect your GitHub repository to automatically deploy your changes.
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { jsonWithErrorMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { v3RunParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const resetIdempotencyKeySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, runParam } =
|
||||
v3RunParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const { taskIdentifier } = submission.value;
|
||||
|
||||
const taskRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
project: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: {
|
||||
slug: envParam,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
idempotencyKey: true,
|
||||
taskIdentifier: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
submission.error = { runParam: ["Run not found"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!taskRun.idempotencyKey) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"This run does not have an idempotency key"
|
||||
);
|
||||
}
|
||||
|
||||
if (taskRun.taskIdentifier !== taskIdentifier) {
|
||||
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: taskRun.runtimeEnvironmentId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"Environment not found"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
await service.call(taskRun.idempotencyKey, taskIdentifier, {
|
||||
...environment,
|
||||
organizationId: environment.project.organizationId,
|
||||
organization: environment.project.organization,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to reset idempotency key", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${error.message}`
|
||||
);
|
||||
} else {
|
||||
logger.error("Failed to reset idempotency key", { error });
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${JSON.stringify(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+68
-8
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
CloudArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
@@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { ToastUI } from "~/components/primitives/Toast";
|
||||
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
v3BatchPath,
|
||||
v3DeploymentVersionPath,
|
||||
v3RunDownloadLogsPath,
|
||||
v3RunIdempotencyKeyResetPath,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
@@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -293,6 +298,28 @@ function RunBody({
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
|
||||
|
||||
// Handle toast messages from the reset action
|
||||
useEffect(() => {
|
||||
if (resetFetcher.data && resetFetcher.state === "idle") {
|
||||
// Check if the response indicates success
|
||||
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant="success"
|
||||
message="Idempotency key reset successfully"
|
||||
t={t as string}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [resetFetcher.data, resetFetcher.state]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
@@ -543,16 +570,49 @@ function RunBody({
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{run.idempotencyKey && (
|
||||
<resetFetcher.Form
|
||||
method="post"
|
||||
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
|
||||
>
|
||||
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
disabled={resetFetcher.state === "submitting"}
|
||||
>
|
||||
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
|
||||
</Button>
|
||||
</resetFetcher.Form>
|
||||
)}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Debounce</Property.Label>
|
||||
<Property.Value>
|
||||
{run.debounce ? (
|
||||
<div>
|
||||
<div className="break-all">Key: {run.debounce.key}</div>
|
||||
<div>Delay: {run.debounce.delay}</div>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams } from "@remix-run/react";
|
||||
export default function Story() {
|
||||
const { tabNumber } = useParams();
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">{tabNumber}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,188 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Tabs } from "~/components/primitives/Tabs";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="w-96 p-8">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "My first tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs"
|
||||
/>
|
||||
<Outlet />
|
||||
<div className="flex items-start justify-center gap-20 px-16 pt-20">
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1 spacing>{"<Tabs/>"} (updates the URL)</Header1>
|
||||
<Paragraph>Variant="underline"</Paragraph>
|
||||
</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-1"
|
||||
variant="underline"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<Paragraph>Variant="pipe-divider"</Paragraph>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-2"
|
||||
variant="pipe-divider"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="flex h-fit flex-col gap-2">
|
||||
<Paragraph>Variant="segmented"</Paragraph>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: "First tab", to: "1" },
|
||||
{ label: "Second tab", to: "2" },
|
||||
{ label: "Third tab", to: "3" },
|
||||
]}
|
||||
layoutId="my-tabs-3"
|
||||
variant="segmented"
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full max-w-2xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1 spacing>{"<ClientTabs/>"}</Header1>
|
||||
<Paragraph>Variant="underline"</Paragraph>
|
||||
</div>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClientTabsList variant="underline">
|
||||
<ClientTabsTrigger
|
||||
value={"tab-1"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-2"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-3"}
|
||||
variant="underline"
|
||||
layoutId="client-tabs-underline"
|
||||
>
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Paragraph spacing>Variant="pipe-divider"</Paragraph>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClientTabsList variant="pipe-divider">
|
||||
<ClientTabsTrigger value={"tab-1"} variant="pipe-divider">
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"tab-2"} variant="pipe-divider">
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"tab-3"} variant="pipe-divider">
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
<div>
|
||||
<Paragraph spacing>Variant="segmented"</Paragraph>
|
||||
<ClientTabs defaultValue="tab-1">
|
||||
<ClientTabsList variant="segmented">
|
||||
<ClientTabsTrigger
|
||||
value={"tab-1"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
First tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-2"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
Second tab
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value={"tab-3"}
|
||||
variant="segmented"
|
||||
layoutId="client-tabs-segmented"
|
||||
>
|
||||
Third tab
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"tab-1"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">1</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-2"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">2</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"tab-3"}>
|
||||
<div className="flex items-center justify-center rounded bg-charcoal-700/50 py-8">
|
||||
<h1 className="text-5xl">3</h1>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import type { GlobalRateLimiter } from "@trigger.dev/redis-worker";
|
||||
import { RateLimiter } from "~/services/rateLimiter.server";
|
||||
|
||||
/**
|
||||
* Creates a global rate limiter for the batch queue that limits
|
||||
* the maximum number of items processed per second across all consumers.
|
||||
*
|
||||
* Uses a token bucket algorithm where:
|
||||
* - `itemsPerSecond` tokens are available per second
|
||||
* - The bucket can hold up to `itemsPerSecond` tokens (burst capacity)
|
||||
*
|
||||
* @param itemsPerSecond - Maximum items to process per second
|
||||
* @returns A GlobalRateLimiter compatible with FairQueue
|
||||
*/
|
||||
export function createBatchGlobalRateLimiter(itemsPerSecond: number): GlobalRateLimiter {
|
||||
const limiter = new RateLimiter({
|
||||
keyPrefix: "batch-queue-global",
|
||||
// Token bucket: refills `itemsPerSecond` tokens every second
|
||||
// Bucket capacity is also `itemsPerSecond` (allows burst up to limit)
|
||||
limiter: Ratelimit.tokenBucket(itemsPerSecond, "1 s", itemsPerSecond),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
async limit() {
|
||||
const result = await limiter.limit("global");
|
||||
return {
|
||||
allowed: result.success,
|
||||
resetAt: result.reset,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Organization } from "@trigger.dev/database";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { RateLimiterConfig } from "~/services/authorizationRateLimitMiddleware.server";
|
||||
import { createRedisRateLimitClient, Duration, RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
const BatchLimitsConfig = z.object({
|
||||
processingConcurrency: z.number().int().default(env.BATCH_CONCURRENCY_LIMIT_DEFAULT),
|
||||
});
|
||||
|
||||
/**
|
||||
* Batch limits configuration for a plan type
|
||||
*/
|
||||
export type BatchLimitsConfig = z.infer<typeof BatchLimitsConfig>;
|
||||
|
||||
const batchLimitsRedisClient = singleton("batchLimitsRedisClient", createBatchLimitsRedisClient);
|
||||
|
||||
function createBatchLimitsRedisClient() {
|
||||
const redisClient = createRedisRateLimitClient({
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
|
||||
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
|
||||
|
||||
const limiter =
|
||||
limiterConfig.type === "fixedWindow"
|
||||
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
|
||||
: limiterConfig.type === "tokenBucket"
|
||||
? Ratelimit.tokenBucket(
|
||||
limiterConfig.refillRate,
|
||||
limiterConfig.interval,
|
||||
limiterConfig.maxTokens
|
||||
)
|
||||
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
|
||||
|
||||
return new RateLimiter({
|
||||
redisClient: batchLimitsRedisClient,
|
||||
keyPrefix: "ratelimit:batch",
|
||||
limiter,
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
|
||||
const defaultRateLimiterConfig: RateLimiterConfig = {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.BATCH_RATE_LIMIT_MAX,
|
||||
};
|
||||
|
||||
if (!batchRateLimitConfig) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
const parsedBatchRateLimitConfig = RateLimiterConfig.safeParse(batchRateLimitConfig);
|
||||
|
||||
if (!parsedBatchRateLimitConfig.success) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
return parsedBatchRateLimitConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter and limits for an organization.
|
||||
* Internally looks up the plan type, but doesn't expose it to callers.
|
||||
*/
|
||||
export async function getBatchLimits(
|
||||
organization: Organization
|
||||
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
|
||||
const rateLimiter = createOrganizationRateLimiter(organization);
|
||||
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
|
||||
return { rateLimiter, config };
|
||||
}
|
||||
|
||||
function resolveBatchLimitsConfig(batchLimitsConfig?: unknown): BatchLimitsConfig {
|
||||
const defaultLimitsConfig: BatchLimitsConfig = {
|
||||
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
if (!batchLimitsConfig) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
const parsedBatchLimitsConfig = BatchLimitsConfig.safeParse(batchLimitsConfig);
|
||||
|
||||
if (!parsedBatchLimitsConfig.success) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
return parsedBatchLimitsConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when batch rate limit is exceeded.
|
||||
* Contains information for constructing a proper 429 response.
|
||||
*/
|
||||
export class BatchRateLimitExceededError extends Error {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
public readonly remaining: number,
|
||||
public readonly resetAt: Date,
|
||||
public readonly itemCount: number
|
||||
) {
|
||||
super(
|
||||
`Batch rate limit exceeded. Attempted to submit ${itemCount} items but only ${remaining} remaining. Limit resets at ${resetAt.toISOString()}`
|
||||
);
|
||||
this.name = "BatchRateLimitExceededError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { uploadPacketToObjectStore, r2 } from "~/v3/r2.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export type BatchPayloadProcessResult = {
|
||||
/** The processed payload - either the original or an R2 path */
|
||||
payload: unknown;
|
||||
/** The payload type - "application/store" if offloaded to R2 */
|
||||
payloadType: string;
|
||||
/** Whether the payload was offloaded to R2 */
|
||||
wasOffloaded: boolean;
|
||||
/** Size of the payload in bytes */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BatchPayloadProcessor handles payload offloading for batch items.
|
||||
*
|
||||
* When a batch item's payload exceeds the configured threshold, it's uploaded
|
||||
* to object storage (R2) and the payload is replaced with the storage path.
|
||||
* This aligns with how single task triggers work via DefaultPayloadProcessor.
|
||||
*
|
||||
* Path format: batch_{batchId}/item_{index}/payload.json
|
||||
*/
|
||||
export class BatchPayloadProcessor {
|
||||
/**
|
||||
* Check if object storage is available for payload offloading.
|
||||
* If not available, large payloads will be stored inline (which may fail for very large payloads).
|
||||
*/
|
||||
isObjectStoreAvailable(): boolean {
|
||||
return r2 !== undefined && env.OBJECT_STORE_BASE_URL !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json")
|
||||
* @param batchId - The batch ID (internal format)
|
||||
* @param itemIndex - The item index within the batch
|
||||
* @param environment - The authenticated environment for R2 path construction
|
||||
* @returns The processed result with potentially offloaded payload
|
||||
*/
|
||||
async process(
|
||||
payload: unknown,
|
||||
payloadType: string,
|
||||
batchId: string,
|
||||
itemIndex: number,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<BatchPayloadProcessResult> {
|
||||
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
|
||||
span.setAttribute("batchId", batchId);
|
||||
span.setAttribute("itemIndex", itemIndex);
|
||||
span.setAttribute("payloadType", payloadType);
|
||||
|
||||
// Create the packet for size checking
|
||||
const packet = this.#createPayloadPacket(payload, payloadType);
|
||||
|
||||
if (!packet.data) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
|
||||
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
|
||||
|
||||
span.setAttribute("payloadSize", size);
|
||||
span.setAttribute("needsOffloading", needsOffloading);
|
||||
span.setAttribute("threshold", threshold);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if object store is available
|
||||
if (!this.isObjectStoreAvailable()) {
|
||||
logger.warn("Payload exceeds threshold but object store is not available", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
size,
|
||||
threshold,
|
||||
});
|
||||
|
||||
// Return without offloading - the payload will be stored inline
|
||||
// This may fail downstream for very large payloads
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload to R2
|
||||
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
|
||||
|
||||
const [uploadError] = await tryCatch(
|
||||
uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
logger.error("Failed to upload batch item payload to object store", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
error: uploadError instanceof Error ? uploadError.message : String(uploadError),
|
||||
});
|
||||
|
||||
// Throw to fail this item - SDK can retry
|
||||
throw new Error(
|
||||
`Failed to upload large payload to object store: ${
|
||||
uploadError instanceof Error ? uploadError.message : String(uploadError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Batch item payload offloaded to R2", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
filename,
|
||||
size,
|
||||
});
|
||||
|
||||
span.setAttribute("wasOffloaded", true);
|
||||
span.setAttribute("offloadPath", filename);
|
||||
|
||||
return {
|
||||
payload: filename,
|
||||
payloadType: "application/store",
|
||||
wasOffloaded: true,
|
||||
size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOPacket from payload for size checking.
|
||||
*/
|
||||
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
|
||||
if (payloadType === "application/json") {
|
||||
// Payload from SDK is already serialized as a string - use directly
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: "application/json" };
|
||||
}
|
||||
// Non-string payloads (e.g., direct API calls with objects) need serialization
|
||||
return { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: payloadType };
|
||||
}
|
||||
|
||||
// For other types, try to stringify
|
||||
try {
|
||||
return { data: JSON.stringify(payload), dataType: payloadType };
|
||||
} catch {
|
||||
return { dataType: payloadType };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,35 @@ import { env } from "~/env.server";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
/**
|
||||
* Extract the queue name from a queue option that may be:
|
||||
* - An object with a string `name` property: { name: "queue-name" }
|
||||
* - A double-wrapped object (bug case): { name: { name: "queue-name", ... } }
|
||||
*
|
||||
* This handles the case where the SDK accidentally double-wraps the queue
|
||||
* option when it's already an object with a name property.
|
||||
*/
|
||||
function extractQueueName(queue: { name?: unknown } | undefined): string | undefined {
|
||||
if (!queue?.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normal case: queue.name is a string
|
||||
if (typeof queue.name === "string") {
|
||||
return queue.name;
|
||||
}
|
||||
|
||||
// Double-wrapped case: queue.name is an object with its own name property
|
||||
if (typeof queue.name === "object" && queue.name !== null && "name" in queue.name) {
|
||||
const innerName = (queue.name as { name: unknown }).name;
|
||||
if (typeof innerName === "string") {
|
||||
return innerName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class DefaultQueueManager implements QueueManager {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
@@ -32,8 +61,8 @@ export class DefaultQueueManager implements QueueManager {
|
||||
// Determine queue name based on lockToVersion and provided options
|
||||
if (lockedBackgroundWorker) {
|
||||
// Task is locked to a specific worker version
|
||||
if (request.body.options?.queue?.name) {
|
||||
const specifiedQueueName = request.body.options.queue.name;
|
||||
const specifiedQueueName = extractQueueName(request.body.options?.queue);
|
||||
if (specifiedQueueName) {
|
||||
// A specific queue name is provided
|
||||
const specifiedQueue = await this.prisma.taskQueue.findFirst({
|
||||
// Validate it exists for the locked worker
|
||||
@@ -126,8 +155,10 @@ export class DefaultQueueManager implements QueueManager {
|
||||
const { taskId, environment, body } = request;
|
||||
const { queue } = body.options ?? {};
|
||||
|
||||
if (queue?.name) {
|
||||
return queue.name;
|
||||
// Use extractQueueName to handle double-wrapped queue objects
|
||||
const queueName = extractQueueName(queue);
|
||||
if (queueName) {
|
||||
return queueName;
|
||||
}
|
||||
|
||||
const defaultQueueName = `task/${taskId}`;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { autoIncrementCounter } from "~/services/autoIncrementCounter.server";
|
||||
import { RunNumberIncrementer, TriggerTaskRequest } from "../types";
|
||||
|
||||
export class DefaultRunNumberIncrementer implements RunNumberIncrementer {
|
||||
async incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
return await autoIncrementCounter.incrementInTransaction(
|
||||
`v3-run:${request.environment.id}:${request.taskId}`,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
@@ -116,6 +117,73 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { existingRun, debounceKey, incomplete, isError } = options;
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
`${request.taskId} (debounced)`,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
||||
},
|
||||
style: {
|
||||
icon: "task-cached",
|
||||
},
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
incomplete,
|
||||
isError,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
// Log a message about the debounced trigger
|
||||
await repository.recordEvent(
|
||||
`Debounced: using existing run with key "${debounceKey}"`,
|
||||
{
|
||||
taskSlug: request.taskId,
|
||||
environment: request.environment,
|
||||
attributes: {
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
context: request.options?.traceContext,
|
||||
parentId: event.spanId,
|
||||
}
|
||||
);
|
||||
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { InitializeBatchOptions } from "@internal/run-engine";
|
||||
import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3";
|
||||
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
||||
import { Evt } from "evt";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchRateLimitExceededError, getBatchLimits } from "../concerns/batchLimits.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
|
||||
|
||||
export type CreateBatchServiceOptions = {
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
|
||||
spanParentAsLink?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
realtimeStreamsVersion?: "v1" | "v2";
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Batch Service (Phase 1 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 1 of the streaming batch API:
|
||||
* 1. Validates entitlement and queue limits
|
||||
* 2. Creates BatchTaskRun in Postgres with status=PENDING, expectedCount set
|
||||
* 3. For batchTriggerAndWait: blocks the parent run immediately
|
||||
* 4. Initializes batch metadata in Redis
|
||||
* 5. Returns batch ID - items are streamed separately via Phase 2
|
||||
*
|
||||
* The batch is NOT sealed until Phase 2 completes.
|
||||
*/
|
||||
export class CreateBatchService extends WithRunEngine {
|
||||
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
||||
private readonly queueConcern: DefaultQueueManager;
|
||||
private readonly validator: DefaultTriggerTaskValidator;
|
||||
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
|
||||
super({ prisma: _prisma });
|
||||
|
||||
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
|
||||
this.validator = new DefaultTriggerTaskValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a batch for 2-phase processing.
|
||||
* Items will be streamed separately via the StreamBatchItemsService.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: CreateBatchRequestBody,
|
||||
options: CreateBatchServiceOptions = {}
|
||||
): Promise<CreateBatchResponse> {
|
||||
try {
|
||||
return await this.traceWithEnv<CreateBatchResponse>(
|
||||
"createBatch()",
|
||||
environment,
|
||||
async (span) => {
|
||||
const { id, friendlyId } = BatchId.generate();
|
||||
|
||||
span.setAttribute("batchId", friendlyId);
|
||||
span.setAttribute("runCount", body.runCount);
|
||||
|
||||
// Validate entitlement
|
||||
const entitlementValidation = await this.validator.validateEntitlement({
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!entitlementValidation.ok) {
|
||||
throw entitlementValidation.error;
|
||||
}
|
||||
|
||||
// Extract plan type from entitlement validation for billing tracking
|
||||
const planType = entitlementValidation.plan?.type;
|
||||
|
||||
// Get batch limits for this organization
|
||||
const { config, rateLimiter } = await getBatchLimits(environment.organization);
|
||||
|
||||
// Check rate limit BEFORE creating the batch
|
||||
// This prevents burst creation of batches that exceed the rate limit
|
||||
const rateResult = await rateLimiter.limit(environment.id, body.runCount);
|
||||
|
||||
if (!rateResult.success) {
|
||||
throw new BatchRateLimitExceededError(
|
||||
rateResult.limit,
|
||||
rateResult.remaining,
|
||||
new Date(rateResult.reset),
|
||||
body.runCount
|
||||
);
|
||||
}
|
||||
|
||||
// Validate queue limits for the expected batch size
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
body.runCount
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot create batch with ${body.runCount} items as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create BatchTaskRun in Postgres with PENDING status
|
||||
// The batch will be sealed (status -> PROCESSING) when items are streamed
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
status: "PENDING",
|
||||
runCount: body.runCount,
|
||||
expectedCount: body.runCount,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2", // 2-phase streaming batch API
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
// Not sealed yet - will be sealed when items stream completes
|
||||
sealed: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.onBatchTaskRunCreated.post(batch);
|
||||
|
||||
// Block parent run if this is a batchTriggerAndWait
|
||||
if (body.parentRunId && body.resumeParentOnCompletion) {
|
||||
await this._engine.blockRunWithCreatedBatch({
|
||||
runId: RunId.fromFriendlyId(body.parentRunId),
|
||||
batchId: batch.id,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
organizationId: environment.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize batch metadata in Redis (without items)
|
||||
const initOptions: InitializeBatchOptions = {
|
||||
batchId: id,
|
||||
friendlyId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
planType,
|
||||
};
|
||||
|
||||
await this._engine.initializeBatch(initOptions);
|
||||
|
||||
logger.info("Batch created", {
|
||||
batchId: friendlyId,
|
||||
runCount: body.runCount,
|
||||
envId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
});
|
||||
|
||||
return {
|
||||
id: friendlyId,
|
||||
runCount: body.runCount,
|
||||
isCached: false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle Prisma unique constraint violations
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("CreateBatchService: Prisma error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
meta: error.meta,
|
||||
});
|
||||
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (
|
||||
Array.isArray(target) &&
|
||||
target.length > 0 &&
|
||||
typeof target[0] === "string" &&
|
||||
target[0].includes("oneTimeUseToken")
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch with a one-time use token as it has already been used."
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch as it has already been created with the same idempotency key."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
type BatchItemNDJSON,
|
||||
type StreamBatchItemsResponse,
|
||||
BatchItemNDJSON as BatchItemNDJSONSchema,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BatchItem, RunEngine } from "@internal/run-engine";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
|
||||
|
||||
export type StreamBatchItemsServiceOptions = {
|
||||
maxItemBytes: number;
|
||||
};
|
||||
|
||||
export type StreamBatchItemsServiceConstructorOptions = {
|
||||
prisma?: PrismaClientOrTransaction;
|
||||
engine?: RunEngine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 2 of the streaming batch API:
|
||||
* 1. Validates batch exists and is in PENDING status
|
||||
* 2. Processes NDJSON stream item by item
|
||||
* 3. Calls engine.enqueueBatchItem() for each item
|
||||
* 4. Tracks accepted/deduplicated counts
|
||||
* 5. On completion: validates count, seals the batch
|
||||
*
|
||||
* The service is designed for streaming and processes items as they arrive,
|
||||
* providing backpressure through the async iterator pattern.
|
||||
*/
|
||||
export class StreamBatchItemsService extends WithRunEngine {
|
||||
private readonly payloadProcessor: BatchPayloadProcessor;
|
||||
|
||||
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
|
||||
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
|
||||
this.payloadProcessor = new BatchPayloadProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a batch friendly ID to its internal ID format.
|
||||
* Throws a ServiceValidationError with 400 status if the ID is malformed.
|
||||
*/
|
||||
private parseBatchFriendlyId(friendlyId: string): string {
|
||||
try {
|
||||
return BatchId.fromFriendlyId(friendlyId);
|
||||
} catch {
|
||||
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream of batch items from an async iterator.
|
||||
* Each item is validated and enqueued to the BatchQueue.
|
||||
* The batch is sealed when the stream completes.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
batchFriendlyId: string,
|
||||
itemsIterator: AsyncIterable<unknown>,
|
||||
options: StreamBatchItemsServiceOptions
|
||||
): Promise<StreamBatchItemsResponse> {
|
||||
return this.traceWithEnv<StreamBatchItemsResponse>(
|
||||
"streamBatchItems()",
|
||||
environment,
|
||||
async (span) => {
|
||||
span.setAttribute("batchId", batchFriendlyId);
|
||||
|
||||
// Convert friendly ID to internal ID
|
||||
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
|
||||
|
||||
// Validate batch exists and belongs to this environment
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
sealed: true,
|
||||
batchVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
|
||||
}
|
||||
|
||||
if (batch.sealed) {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is already sealed and cannot accept more items`
|
||||
);
|
||||
}
|
||||
|
||||
if (batch.status !== "PENDING") {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
|
||||
);
|
||||
}
|
||||
|
||||
let itemsAccepted = 0;
|
||||
let itemsDeduplicated = 0;
|
||||
let lastIndex = -1;
|
||||
|
||||
// Process items from the stream
|
||||
for await (const rawItem of itemsIterator) {
|
||||
// Parse and validate the item
|
||||
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
|
||||
if (!parseResult.success) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid item at index ${lastIndex + 1}: ${parseResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const item = parseResult.data;
|
||||
lastIndex = item.index;
|
||||
|
||||
// Validate index is within expected range
|
||||
if (item.index >= batch.runCount) {
|
||||
throw new ServiceValidationError(
|
||||
`Item index ${item.index} exceeds batch runCount ${batch.runCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the original payload type
|
||||
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
|
||||
|
||||
// Process payload - offload to R2 if it exceeds threshold
|
||||
const processedPayload = await this.payloadProcessor.process(
|
||||
item.payload,
|
||||
originalPayloadType,
|
||||
batchId,
|
||||
item.index,
|
||||
environment
|
||||
);
|
||||
|
||||
// Convert to BatchItem format with potentially offloaded payload
|
||||
const batchItem: BatchItem = {
|
||||
task: item.task,
|
||||
payload: processedPayload.payload,
|
||||
payloadType: processedPayload.payloadType,
|
||||
options: item.options,
|
||||
};
|
||||
|
||||
// Enqueue the item
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
item.index,
|
||||
batchItem
|
||||
);
|
||||
|
||||
if (result.enqueued) {
|
||||
itemsAccepted++;
|
||||
} else {
|
||||
itemsDeduplicated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual enqueued count from Redis
|
||||
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
|
||||
|
||||
// Validate we received the expected number of items
|
||||
if (enqueuedCount !== batch.runCount) {
|
||||
logger.warn("Batch item count mismatch", {
|
||||
batchId: batchFriendlyId,
|
||||
expected: batch.runCount,
|
||||
received: enqueuedCount,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
});
|
||||
|
||||
// Don't seal the batch if count doesn't match
|
||||
// Return sealed: false so client knows to retry with missing items
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: false,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Seal the batch - use conditional update to prevent TOCTOU race
|
||||
// Another concurrent request may have already sealed this batch
|
||||
const now = new Date();
|
||||
const sealResult = await this._prisma.batchTaskRun.updateMany({
|
||||
where: {
|
||||
id: batchId,
|
||||
sealed: false,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: {
|
||||
sealed: true,
|
||||
sealedAt: now,
|
||||
status: "PROCESSING",
|
||||
processingStartedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if we won the race to seal the batch
|
||||
if (sealResult.count === 0) {
|
||||
// Another request sealed the batch first - re-query to check current state
|
||||
const currentBatch = await this._prisma.batchTaskRun.findUnique({
|
||||
where: { id: batchId },
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
sealed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentBatch?.sealed && currentBatch.status === "PROCESSING") {
|
||||
// The batch was sealed by another request - this is fine, the goal was achieved
|
||||
logger.info("Batch already sealed by concurrent request", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
span.setAttribute("sealedByConcurrentRequest", true);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch is in an unexpected state - fail with error
|
||||
const actualStatus = currentBatch?.status ?? "unknown";
|
||||
const actualSealed = currentBatch?.sealed ?? "unknown";
|
||||
logger.error("Batch seal race condition: unexpected state", {
|
||||
batchId: batchFriendlyId,
|
||||
expectedStatus: "PENDING",
|
||||
actualStatus,
|
||||
expectedSealed: false,
|
||||
actualSealed,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Batch sealed and ready for processing", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
totalEnqueued: enqueuedCount,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NDJSON parser transform stream.
|
||||
*
|
||||
* Converts a stream of Uint8Array chunks into parsed JSON objects.
|
||||
* Each line in the NDJSON is parsed independently.
|
||||
*
|
||||
* Uses byte-buffer accumulation to:
|
||||
* - Prevent OOM from unbounded string buffers
|
||||
* - Properly handle multibyte UTF-8 characters across chunk boundaries
|
||||
* - Check size limits on raw bytes before decoding
|
||||
*
|
||||
* @param maxItemBytes - Maximum allowed bytes per line (item)
|
||||
* @returns TransformStream that outputs parsed JSON objects
|
||||
*/
|
||||
export function createNdjsonParserStream(
|
||||
maxItemBytes: number
|
||||
): TransformStream<Uint8Array, unknown> {
|
||||
// Single decoder instance, reused for all lines
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
// Byte buffer: array of chunks with tracked total length
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
const NEWLINE_BYTE = 0x0a; // '\n'
|
||||
|
||||
/**
|
||||
* Concatenate all chunks into a single Uint8Array
|
||||
*/
|
||||
function concatenateChunks(): Uint8Array {
|
||||
if (chunks.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunks.length === 1) {
|
||||
return chunks[0];
|
||||
}
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first newline byte in the buffer.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
function findNewlineIndex(): number {
|
||||
let globalIndex = 0;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.byteLength; i++) {
|
||||
if (chunk[i] === NEWLINE_BYTE) {
|
||||
return globalIndex + i;
|
||||
}
|
||||
}
|
||||
globalIndex += chunk.byteLength;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bytes from the buffer up to (but not including) the given index,
|
||||
* and remove those bytes plus the delimiter from the buffer.
|
||||
*/
|
||||
function extractLine(newlineIndex: number): Uint8Array {
|
||||
const fullBuffer = concatenateChunks();
|
||||
const lineBytes = fullBuffer.slice(0, newlineIndex);
|
||||
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
|
||||
|
||||
// Reset buffer with remaining bytes
|
||||
if (remaining.byteLength > 0) {
|
||||
chunks = [remaining];
|
||||
totalBytes = remaining.byteLength;
|
||||
} else {
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
}
|
||||
|
||||
return lineBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line from bytes, handling whitespace trimming.
|
||||
* Returns the parsed object or null for empty lines.
|
||||
*/
|
||||
function parseLine(
|
||||
lineBytes: Uint8Array,
|
||||
controller: TransformStreamDefaultController<unknown>
|
||||
): void {
|
||||
lineNumber++;
|
||||
|
||||
// Decode the line bytes (stream: false since this is a complete line)
|
||||
let lineText: string;
|
||||
try {
|
||||
lineText = decoder.decode(lineBytes, { stream: false });
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = lineText.trim();
|
||||
if (!trimmed) {
|
||||
return; // Skip empty lines
|
||||
}
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
controller.enqueue(obj);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new TransformStream<Uint8Array, unknown>({
|
||||
transform(chunk, controller) {
|
||||
// Append chunk to buffer
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.byteLength;
|
||||
|
||||
// Process all complete lines in the buffer
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = findNewlineIndex()) !== -1) {
|
||||
// Check size limit BEFORE extracting/decoding (bytes up to newline)
|
||||
if (newlineIndex > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${newlineIndex})`
|
||||
);
|
||||
}
|
||||
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
parseLine(lineBytes, controller);
|
||||
}
|
||||
|
||||
// Check if the remaining buffer (incomplete line) exceeds the limit
|
||||
// This prevents OOM from a single huge line without newlines
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (buffered: ${totalBytes}, no newline found)`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
// Flush any remaining bytes from the decoder's internal state
|
||||
// This handles multibyte characters that may have been split across chunks
|
||||
decoder.decode(new Uint8Array(0), { stream: false });
|
||||
|
||||
// Process any remaining buffered data (no trailing newline case)
|
||||
if (totalBytes === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size limit before processing final line
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${totalBytes})`
|
||||
);
|
||||
}
|
||||
|
||||
const finalBytes = concatenateChunks();
|
||||
parseLine(finalBytes, controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ReadableStream into an AsyncIterable.
|
||||
* Useful for processing streams with for-await-of loops.
|
||||
*/
|
||||
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
|
||||
import type {
|
||||
PayloadProcessor,
|
||||
QueueManager,
|
||||
RunNumberIncrementer,
|
||||
TraceEventConcern,
|
||||
TriggerRacepoints,
|
||||
TriggerRacepointSystem,
|
||||
@@ -54,7 +53,6 @@ export class RunEngineTriggerTaskService {
|
||||
private readonly validator: TriggerTaskValidator;
|
||||
private readonly payloadProcessor: PayloadProcessor;
|
||||
private readonly idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
private readonly runNumberIncrementer: RunNumberIncrementer;
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
private readonly tracer: Tracer;
|
||||
@@ -69,7 +67,6 @@ export class RunEngineTriggerTaskService {
|
||||
validator: TriggerTaskValidator;
|
||||
payloadProcessor: PayloadProcessor;
|
||||
idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
runNumberIncrementer: RunNumberIncrementer;
|
||||
traceEventConcern: TraceEventConcern;
|
||||
tracer: Tracer;
|
||||
metadataMaximumSize: number;
|
||||
@@ -81,7 +78,6 @@ export class RunEngineTriggerTaskService {
|
||||
this.validator = opts.validator;
|
||||
this.payloadProcessor = opts.payloadProcessor;
|
||||
this.idempotencyKeyConcern = opts.idempotencyKeyConcern;
|
||||
this.runNumberIncrementer = opts.runNumberIncrementer;
|
||||
this.tracer = opts.tracer;
|
||||
this.traceEventConcern = opts.traceEventConcern;
|
||||
this.metadataMaximumSize = opts.metadataMaximumSize;
|
||||
@@ -164,10 +160,34 @@ export class RunEngineTriggerTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(body.options?.delay));
|
||||
// Parse delay from either explicit delay option or debounce.delay
|
||||
const delaySource = body.options?.delay ?? body.options?.debounce?.delay;
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(delaySource));
|
||||
|
||||
if (parseDelayError) {
|
||||
throw new ServiceValidationError(`Invalid delay ${body.options?.delay}`);
|
||||
throw new ServiceValidationError(`Invalid delay ${delaySource}`);
|
||||
}
|
||||
|
||||
// Validate debounce options
|
||||
if (body.options?.debounce) {
|
||||
if (!delayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Debounce requires a valid delay duration. Provided: ${body.options.debounce.delay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Always validate debounce.delay separately since it's used for rescheduling
|
||||
// This catches the case where options.delay is valid but debounce.delay is invalid
|
||||
const [debounceDelayError, debounceDelayUntil] = await tryCatch(
|
||||
parseDelay(body.options.debounce.delay)
|
||||
);
|
||||
|
||||
if (debounceDelayError || !debounceDelayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ttl =
|
||||
@@ -271,97 +291,129 @@ export class RunEngineTriggerTaskService {
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
async (event, store) => {
|
||||
const result = await this.runNumberIncrementer.incrementRunNumber(
|
||||
triggerRequest,
|
||||
async (num) => {
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
return { run: taskRun, error, isCached: false };
|
||||
}
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
debounce: body.options?.debounce,
|
||||
// When debouncing with triggerAndWait, create a span for the debounced trigger
|
||||
onDebounced:
|
||||
body.options?.debounce && body.options?.resumeParentOnCompletion
|
||||
? async ({ existingRun, waitpoint, debounceKey }) => {
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
|
||||
: spanEvent.spanId;
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
// If the returned run has a different friendlyId, it was debounced.
|
||||
// For triggerAndWait: stop the outer span since a replacement debounced span was created via onDebounced.
|
||||
// For regular trigger: let the span complete normally - no replacement span needed since the
|
||||
// original run already has its span from when it was first created.
|
||||
if (
|
||||
taskRun.friendlyId !== runFriendlyId &&
|
||||
body.options?.debounce &&
|
||||
body.options?.resumeParentOnCompletion
|
||||
) {
|
||||
event.stop();
|
||||
}
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
const result = { run: taskRun, error, isCached: false };
|
||||
|
||||
if (result?.error) {
|
||||
throw new ServiceValidationError(
|
||||
taskRunErrorToString(taskRunErrorEnhancer(result.error))
|
||||
@@ -374,7 +426,13 @@ export class RunEngineTriggerTaskService {
|
||||
} catch (error) {
|
||||
if (error instanceof RunDuplicateIdempotencyKeyError) {
|
||||
//retry calling this function, because this time it will return the idempotent run
|
||||
return await this.call({ taskId, environment, body, options, attempt: attempt + 1 });
|
||||
return await this.call({
|
||||
taskId,
|
||||
environment,
|
||||
body,
|
||||
options: { ...options, runFriendlyId },
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof RunOneTimeUseTokenError) {
|
||||
|
||||
@@ -76,13 +76,6 @@ export interface PayloadProcessor {
|
||||
process(request: TriggerTaskRequest): Promise<IOPacket>;
|
||||
}
|
||||
|
||||
export interface RunNumberIncrementer {
|
||||
incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined>;
|
||||
}
|
||||
|
||||
export interface TagValidationParams {
|
||||
tags?: string[] | string;
|
||||
}
|
||||
@@ -138,6 +131,12 @@ export type TracedEventSpan = {
|
||||
};
|
||||
setAttribute: (key: string, value: string) => void;
|
||||
failWithError: (error: TaskRunError) => void;
|
||||
/**
|
||||
* Stop the span without writing any event.
|
||||
* Used when a debounced run is returned - the span for the debounced
|
||||
* trigger is created separately via traceDebouncedRun.
|
||||
*/
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface TraceEventConcern {
|
||||
@@ -157,6 +156,17 @@ export interface TraceEventConcern {
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export type TriggerRacepoints = "idempotencyKey";
|
||||
|
||||
@@ -61,6 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
"/api/v1/auth/jwt/claims",
|
||||
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
|
||||
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
|
||||
/^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/*
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Authenticator } from "remix-auth";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { addEmailLinkStrategy } from "./emailAuth.server";
|
||||
import { addGitHubStrategy } from "./gitHubAuth.server";
|
||||
import { addGoogleStrategy } from "./googleAuth.server";
|
||||
import { sessionStorage } from "./sessionStorage.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
@@ -13,10 +14,18 @@ const isGithubAuthSupported =
|
||||
typeof env.AUTH_GITHUB_CLIENT_ID === "string" &&
|
||||
typeof env.AUTH_GITHUB_CLIENT_SECRET === "string";
|
||||
|
||||
const isGoogleAuthSupported =
|
||||
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
|
||||
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
|
||||
|
||||
if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) {
|
||||
addGitHubStrategy(authenticator, env.AUTH_GITHUB_CLIENT_ID, env.AUTH_GITHUB_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
addEmailLinkStrategy(authenticator);
|
||||
|
||||
export { authenticator, isGithubAuthSupported };
|
||||
export { authenticator, isGithubAuthSupported, isGoogleAuthSupported };
|
||||
|
||||
@@ -20,7 +20,7 @@ export function addGitHubStrategy(
|
||||
async ({ extraParams, profile }) => {
|
||||
const emails = profile.emails;
|
||||
|
||||
if (!emails) {
|
||||
if (!emails?.length) {
|
||||
throw new Error("GitHub login requires an email address");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Authenticator } from "remix-auth";
|
||||
import { GoogleStrategy } from "remix-auth-google";
|
||||
import { env } from "~/env.server";
|
||||
import { findOrCreateUser } from "~/models/user.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { logger } from "./logger.server";
|
||||
import { postAuthentication } from "./postAuth.server";
|
||||
|
||||
export function addGoogleStrategy(
|
||||
authenticator: Authenticator<AuthUser>,
|
||||
clientID: string,
|
||||
clientSecret: string
|
||||
) {
|
||||
const googleStrategy = new GoogleStrategy(
|
||||
{
|
||||
clientID,
|
||||
clientSecret,
|
||||
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
|
||||
},
|
||||
async ({ extraParams, profile }) => {
|
||||
const emails = profile.emails;
|
||||
|
||||
if (!emails?.length) {
|
||||
throw new Error("Google login requires an email address");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Google login", {
|
||||
emails,
|
||||
profile,
|
||||
extraParams,
|
||||
});
|
||||
|
||||
const { user, isNewUser } = await findOrCreateUser({
|
||||
email: emails[0].value,
|
||||
authenticationMethod: "GOOGLE",
|
||||
authenticationProfile: profile,
|
||||
authenticationExtraParams: extraParams,
|
||||
});
|
||||
|
||||
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Google login failed", { error: JSON.stringify(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authenticator.use(googleStrategy);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createCookie } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export type LastAuthMethod = "github" | "google" | "email";
|
||||
|
||||
// Cookie that persists for 1 year to remember the user's last login method
|
||||
export const lastAuthMethodCookie = createCookie("last-auth-method", {
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
export async function getLastAuthMethod(request: Request): Promise<LastAuthMethod | null> {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const value = await lastAuthMethodCookie.parse(cookie);
|
||||
if (value === "github" || value === "google" || value === "email") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setLastAuthMethodHeader(method: LastAuthMethod): Promise<string> {
|
||||
return lastAuthMethodCookie.serialize(method);
|
||||
}
|
||||
@@ -17,6 +17,6 @@ function createRequestIdempotencyInstance() {
|
||||
},
|
||||
logLevel: env.REQUEST_IDEMPOTENCY_LOG_LEVEL,
|
||||
ttlInMs: env.REQUEST_IDEMPOTENCY_TTL_IN_MS,
|
||||
types: ["batch-trigger", "trigger"],
|
||||
types: ["batch-trigger", "trigger", "create-batch"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,15 +288,17 @@ export function v3RunPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
run: v3RunForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
|
||||
const query = searchParams ? `?${searchParams.toString()}` : "";
|
||||
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}${query}`;
|
||||
}
|
||||
|
||||
export function v3RunRedirectPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
run: v3RunForPath
|
||||
run: v3RunForPath,
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
|
||||
}
|
||||
@@ -310,9 +312,12 @@ export function v3RunSpanPath(
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath,
|
||||
span: v3SpanForPath
|
||||
span: v3SpanForPath,
|
||||
searchParams?: URLSearchParams
|
||||
) {
|
||||
return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`;
|
||||
searchParams = searchParams ?? new URLSearchParams();
|
||||
searchParams.set("span", span.spanId);
|
||||
return `${v3RunPath(organization, project, environment, run, searchParams)}`;
|
||||
}
|
||||
|
||||
export function v3RunStreamingPath(
|
||||
@@ -324,6 +329,17 @@ export function v3RunStreamingPath(
|
||||
return `${v3RunPath(organization, project, environment, run)}/stream`;
|
||||
}
|
||||
|
||||
export function v3RunIdempotencyKeyResetPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
run: v3RunForPath
|
||||
) {
|
||||
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
|
||||
project
|
||||
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
|
||||
}
|
||||
|
||||
export function v3SchedulesPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
@@ -407,7 +423,7 @@ export function v3BatchPath(
|
||||
environment: EnvironmentForPath,
|
||||
batch: { friendlyId: string }
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/batches?id=${batch.friendlyId}`;
|
||||
return `${v3BatchesPath(organization, project, environment)}/${batch.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3BatchRunsPath(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { logger } from "~/services/logger.server";
|
||||
import { requestIdempotency } from "~/services/requestIdempotencyInstance.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
type RequestIdempotencyType = "batch-trigger" | "trigger";
|
||||
type RequestIdempotencyType = "batch-trigger" | "trigger" | "create-batch";
|
||||
|
||||
export type IdempotencyConfig<T, R> = {
|
||||
requestType: RequestIdempotencyType;
|
||||
|
||||
@@ -6,8 +6,23 @@ import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { BatchTriggerV3Service } from "./services/batchTriggerV3.server";
|
||||
// Import engine to ensure it's initialized (which initializes BatchQueue for v2 batches)
|
||||
import { engine } from "./runEngine.server";
|
||||
|
||||
/**
|
||||
* Legacy batch trigger worker for processing v3 and run engine v1 batches.
|
||||
*
|
||||
* NOTE: Run Engine v2 batches (batchVersion: "runengine:v2") use the new BatchQueue
|
||||
* system with Deficit Round Robin scheduling, which is encapsulated within the RunEngine.
|
||||
* See runEngine.server.ts for the configuration.
|
||||
*
|
||||
* This worker is kept for backwards compatibility with:
|
||||
* - v3 batches (batchVersion: "v3") - handled by BatchTriggerV3Service
|
||||
* - Run Engine v1 batches (batchVersion: "runengine:v1") - handled by RunEngineBatchTriggerService
|
||||
*/
|
||||
function initializeWorker() {
|
||||
// Ensure the engine (and its BatchQueue) is initialized
|
||||
void engine;
|
||||
const redisOptions = {
|
||||
keyPrefix: "batch-trigger:worker:",
|
||||
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST,
|
||||
|
||||
@@ -342,7 +342,10 @@ async function getEcrRepository({
|
||||
|
||||
return result.repositories[0];
|
||||
} catch (error) {
|
||||
if (error instanceof RepositoryNotFoundException) {
|
||||
if (
|
||||
error instanceof RepositoryNotFoundException ||
|
||||
(error instanceof Error && error.message?.includes("does not exist"))
|
||||
) {
|
||||
logger.debug("ECR repository not found: RepositoryNotFoundException", {
|
||||
repositoryName,
|
||||
region,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { RunEngine } from "@internal/run-engine";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { createBatchGlobalRateLimiter } from "~/runEngine/concerns/batchGlobalRateLimiter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { defaultMachine, getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { allMachines } from "./machinePresets.server";
|
||||
import { meter, tracer } from "./tracer.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export const engine = singleton("RunEngine", createRunEngine);
|
||||
|
||||
@@ -155,6 +156,36 @@ function createRunEngine() {
|
||||
};
|
||||
},
|
||||
},
|
||||
// BatchQueue with DRR scheduling for fair batch processing
|
||||
// Consumers are controlled by options.worker.disabled (same as main worker)
|
||||
batchQueue: {
|
||||
redis: {
|
||||
keyPrefix: "engine:",
|
||||
port: env.BATCH_TRIGGER_WORKER_REDIS_PORT ?? undefined,
|
||||
host: env.BATCH_TRIGGER_WORKER_REDIS_HOST ?? undefined,
|
||||
username: env.BATCH_TRIGGER_WORKER_REDIS_USERNAME ?? undefined,
|
||||
password: env.BATCH_TRIGGER_WORKER_REDIS_PASSWORD ?? undefined,
|
||||
enableAutoPipelining: true,
|
||||
...(env.BATCH_TRIGGER_WORKER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
drr: {
|
||||
quantum: env.BATCH_QUEUE_DRR_QUANTUM,
|
||||
maxDeficit: env.BATCH_QUEUE_MAX_DEFICIT,
|
||||
},
|
||||
consumerCount: env.BATCH_QUEUE_CONSUMER_COUNT,
|
||||
consumerIntervalMs: env.BATCH_QUEUE_CONSUMER_INTERVAL_MS,
|
||||
// Default processing concurrency when no specific limit is set
|
||||
// This is overridden per-batch based on the plan type at batch creation
|
||||
defaultConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
// Optional global rate limiter - limits max items/sec processed across all consumers
|
||||
globalRateLimiter: env.BATCH_QUEUE_GLOBAL_RATE_LIMIT
|
||||
? createBatchGlobalRateLimiter(env.BATCH_QUEUE_GLOBAL_RATE_LIMIT)
|
||||
: undefined,
|
||||
},
|
||||
// Debounce configuration
|
||||
debounce: {
|
||||
maxDebounceDurationMs: env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
return engine;
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { CompleteBatchResult } from "@internal/run-engine";
|
||||
import { SpanKind } from "@internal/tracing";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { createJsonErrorObject, sanitizeError } from "@trigger.dev/core/v3";
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { $replica } from "~/db.server";
|
||||
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
|
||||
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server";
|
||||
import { reportInvocationUsage } from "~/services/platform.v3.server";
|
||||
import { MetadataTooLargeError } from "~/utils/packets";
|
||||
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
|
||||
import { tracer } from "~/v3/tracer.server";
|
||||
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
|
||||
import { recordRunDebugLog, resolveEventRepositoryForStore } from "./eventRepository/index.server";
|
||||
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
|
||||
import { engine } from "./runEngine.server";
|
||||
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
|
||||
import { resolveEventRepositoryForStore, recordRunDebugLog } from "./eventRepository/index.server";
|
||||
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
|
||||
|
||||
export function registerRunEngineEventBusHandlers() {
|
||||
engine.eventBus.on("runSucceeded", async ({ time, run }) => {
|
||||
@@ -626,3 +632,206 @@ export function registerRunEngineEventBusHandlers() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the BatchQueue processing callbacks.
|
||||
* These handle creating runs from batch items and completing batches.
|
||||
*
|
||||
* Payload handling:
|
||||
* - If payloadType is "application/store", the payload is an R2 path (already offloaded)
|
||||
* - DefaultPayloadProcessor in TriggerTaskService will pass it through without re-offloading
|
||||
* - The run engine will download from R2 when the task executes
|
||||
*/
|
||||
export function setupBatchQueueCallbacks() {
|
||||
// Item processing callback - creates a run for each batch item
|
||||
engine.setBatchProcessItemCallback(async ({ batchId, friendlyId, itemIndex, item, meta }) => {
|
||||
return tracer.startActiveSpan(
|
||||
"batch.processItem",
|
||||
{
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: {
|
||||
"batch.id": friendlyId,
|
||||
"batch.item_index": itemIndex,
|
||||
"batch.task": item.task,
|
||||
"batch.environment_id": meta.environmentId,
|
||||
"batch.parent_run_id": meta.parentRunId ?? "",
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const environment = await findEnvironmentById(meta.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
span.setAttribute("batch.result.error", "Environment not found");
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Environment not found",
|
||||
errorCode: "ENVIRONMENT_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
const triggerTaskService = new TriggerTaskService();
|
||||
|
||||
// Normalize payload - for application/store (R2 paths), this passes through as-is
|
||||
const payload = normalizePayload(item.payload, item.payloadType);
|
||||
|
||||
const result = await triggerTaskService.call(
|
||||
item.task,
|
||||
environment,
|
||||
{
|
||||
payload,
|
||||
options: {
|
||||
...(item.options as Record<string, unknown>),
|
||||
payloadType: item.payloadType,
|
||||
parentRunId: meta.parentRunId,
|
||||
resumeParentOnCompletion: meta.resumeParentOnCompletion,
|
||||
parentBatch: batchId,
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerVersion: meta.triggerVersion,
|
||||
traceContext: meta.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: meta.spanParentAsLink,
|
||||
batchId,
|
||||
batchIndex: itemIndex,
|
||||
skipChecks: true, // Already validated at batch level
|
||||
realtimeStreamsVersion: meta.realtimeStreamsVersion,
|
||||
planType: meta.planType,
|
||||
},
|
||||
"V2"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
span.setAttribute("batch.result.run_id", result.run.friendlyId);
|
||||
span.end();
|
||||
return { success: true as const, runId: result.run.friendlyId };
|
||||
} else {
|
||||
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: "TriggerTaskService returned undefined",
|
||||
errorCode: "TRIGGER_FAILED",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
span.setAttribute(
|
||||
"batch.result.error",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
||||
span.end();
|
||||
return {
|
||||
success: false as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorCode: "TRIGGER_ERROR",
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Batch completion callback - updates Postgres with results
|
||||
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
|
||||
const { batchId, runIds, successfulRunCount, failedRunCount, failures } = result;
|
||||
|
||||
// Determine final status
|
||||
let status: BatchTaskRunStatus;
|
||||
if (failedRunCount > 0 && successfulRunCount === 0) {
|
||||
status = "ABORTED";
|
||||
} else if (failedRunCount > 0) {
|
||||
status = "PARTIAL_FAILED";
|
||||
} else {
|
||||
status = "PENDING"; // All runs created, waiting for completion
|
||||
}
|
||||
|
||||
try {
|
||||
// Use a transaction to ensure atomicity of batch update and error record creation
|
||||
// skipDuplicates handles idempotency when callback is retried (relies on unique constraint)
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update BatchTaskRun
|
||||
await tx.batchTaskRun.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status,
|
||||
runIds,
|
||||
successfulRunCount,
|
||||
failedRunCount,
|
||||
completedAt: status === "ABORTED" ? new Date() : undefined,
|
||||
processingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create error records if there were failures
|
||||
if (failures.length > 0) {
|
||||
await tx.batchTaskRunError.createMany({
|
||||
data: failures.map((failure) => ({
|
||||
batchTaskRunId: batchId,
|
||||
index: failure.index,
|
||||
taskIdentifier: failure.taskIdentifier,
|
||||
payload: failure.payload,
|
||||
options: failure.options as Prisma.InputJsonValue | undefined,
|
||||
error: failure.error,
|
||||
errorCode: failure.errorCode,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to complete the batch (handles waitpoint completion if all runs are done)
|
||||
if (status !== "ABORTED") {
|
||||
await engine.tryCompleteBatch({ batchId });
|
||||
}
|
||||
|
||||
logger.info("Batch completion handled", {
|
||||
batchId,
|
||||
status,
|
||||
successfulRunCount,
|
||||
failedRunCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle batch completion", {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Re-throw to preserve Redis data for retry (BatchQueue expects errors to propagate)
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("BatchQueue callbacks configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the payload from BatchQueue.
|
||||
*
|
||||
* Handles different payload types:
|
||||
* - "application/store": Already offloaded to R2, payload is the path - pass through as-is
|
||||
* - "application/json": May be a pre-serialized JSON string - parse to avoid double-stringification
|
||||
* - Other types: Pass through as-is
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json", "application/store")
|
||||
*/
|
||||
function normalizePayload(payload: unknown, payloadType?: string): unknown {
|
||||
// Only process "application/json" payloads
|
||||
// For all other types (including undefined), return as-is
|
||||
if (payloadType !== "application/json") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// For JSON payloads, if payload is a string, try to parse it
|
||||
// This handles pre-serialized JSON from the SDK
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
// If it's not valid JSON, return as-is
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -849,7 +849,7 @@ export class BatchTriggerV3Service extends BaseService {
|
||||
triggerVersion: options?.triggerVersion,
|
||||
traceContext: options?.traceContext,
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
batchId: batch.friendlyId,
|
||||
batchId: batch.id,
|
||||
skipChecks: true,
|
||||
runFriendlyId: task.runId,
|
||||
realtimeStreamsVersion: options?.realtimeStreamsVersion,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
|
||||
import { BuildServerMetadata, type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { env } from "~/env.server";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
@@ -190,6 +190,21 @@ export class InitializeDeploymentService extends BaseService {
|
||||
isNativeBuild: payload.isNativeBuild,
|
||||
});
|
||||
|
||||
const buildServerMetadata: BuildServerMetadata | undefined =
|
||||
payload.isNativeBuild || payload.buildId
|
||||
? {
|
||||
buildId: payload.buildId,
|
||||
...(payload.isNativeBuild
|
||||
? {
|
||||
isNativeBuild: payload.isNativeBuild,
|
||||
artifactKey: payload.artifactKey,
|
||||
skipPromotion: payload.skipPromotion,
|
||||
configFilePath: payload.configFilePath,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const deployment = await this._prisma.workerDeployment.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("deployment"),
|
||||
@@ -200,12 +215,14 @@ export class InitializeDeploymentService extends BaseService {
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
externalBuildData,
|
||||
buildServerMetadata,
|
||||
triggeredById: triggeredBy?.id,
|
||||
type: payload.type,
|
||||
imageReference: imageRef,
|
||||
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
|
||||
git: payload.gitMeta ?? undefined,
|
||||
runtime: payload.runtime ?? undefined,
|
||||
triggeredVia: payload.triggeredVia ?? undefined,
|
||||
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export class ResetIdempotencyKeyService extends BaseService {
|
||||
public async call(
|
||||
idempotencyKey: string,
|
||||
taskIdentifier: string,
|
||||
authenticatedEnv: AuthenticatedEnvironment
|
||||
): Promise<{ id: string }> {
|
||||
const { count } = await this._prisma.taskRun.updateMany({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
taskIdentifier,
|
||||
runtimeEnvironmentId: authenticatedEnv.id,
|
||||
},
|
||||
data: {
|
||||
idempotencyKey: null,
|
||||
idempotencyKeyExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new ServiceValidationError(
|
||||
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Reset idempotency key: ${idempotencyKey} for task: ${taskIdentifier} in env: ${authenticatedEnv.id}, affected ${count} run(s)`
|
||||
);
|
||||
|
||||
return { id: idempotencyKey };
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { env } from "~/env.server";
|
||||
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
|
||||
import { DefaultPayloadProcessor } from "~/runEngine/concerns/payloads.server";
|
||||
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
|
||||
import { DefaultRunNumberIncrementer } from "~/runEngine/concerns/runNumbers.server";
|
||||
import { DefaultTraceEventsConcern } from "~/runEngine/concerns/traceEvents.server";
|
||||
import { RunEngineTriggerTaskService } from "~/runEngine/services/triggerTask.server";
|
||||
import { DefaultTriggerTaskValidator } from "~/runEngine/validators/triggerTaskValidator";
|
||||
@@ -106,7 +105,6 @@ export class TriggerTaskService extends WithRunEngine {
|
||||
this._engine,
|
||||
traceEventConcern
|
||||
),
|
||||
runNumberIncrementer: new DefaultRunNumberIncrementer(),
|
||||
traceEventConcern,
|
||||
tracer: tracer,
|
||||
metadataMaximumSize: env.TASK_RUN_METADATA_MAXIMUM_SIZE,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
|
||||
"start:local": "cross-env node --max-old-space-size=8192 ./build/server.js",
|
||||
"typecheck": "tsc --noEmit -p ./tsconfig.check.json",
|
||||
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json",
|
||||
"db:seed": "tsx seed.mts",
|
||||
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
|
||||
"test": "vitest --no-file-parallelism",
|
||||
@@ -191,6 +191,7 @@
|
||||
"remix-auth": "^3.6.0",
|
||||
"remix-auth-email-link": "2.0.2",
|
||||
"remix-auth-github": "^1.6.0",
|
||||
"remix-auth-google": "^2.0.0",
|
||||
"remix-typedjson": "0.3.1",
|
||||
"remix-utils": "^7.7.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
@@ -287,4 +288,4 @@
|
||||
"engines": {
|
||||
"node": ">=18.19.0 || >=20.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
-39
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "./app/db.server";
|
||||
import { createOrganization } from "./app/models/organization.server";
|
||||
import { createProject } from "./app/models/project.server";
|
||||
import { AuthenticationMethod } from "@trigger.dev/database";
|
||||
import { AuthenticationMethod, Organization, Prisma, User } from "@trigger.dev/database";
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Starting seed...");
|
||||
@@ -71,46 +71,11 @@ async function seed() {
|
||||
|
||||
// Create or find each project
|
||||
for (const projectConfig of referenceProjects) {
|
||||
let project = await prisma.project.findUnique({
|
||||
where: { externalRef: projectConfig.externalRef },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
console.log(`Creating project: ${projectConfig.name}...`);
|
||||
project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: projectConfig.name,
|
||||
userId: user.id,
|
||||
version: "v3",
|
||||
});
|
||||
|
||||
// Update the externalRef to match the expected value
|
||||
project = await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { externalRef: projectConfig.externalRef },
|
||||
});
|
||||
|
||||
console.log(`✅ Created project: ${project.name} (${project.externalRef})`);
|
||||
} else {
|
||||
console.log(`✅ Project already exists: ${project.name} (${project.externalRef})`);
|
||||
}
|
||||
|
||||
// List the environments for this project
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: {
|
||||
slug: true,
|
||||
type: true,
|
||||
apiKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Environments for ${project.name}:`);
|
||||
for (const env of environments) {
|
||||
console.log(` - ${env.type.toLowerCase()} (${env.slug}): ${env.apiKey}`);
|
||||
}
|
||||
await findOrCreateProject(projectConfig.name, organization, user.id, projectConfig.externalRef);
|
||||
}
|
||||
|
||||
await createBatchLimitOrgs(user);
|
||||
|
||||
console.log("\n🎉 Seed complete!\n");
|
||||
console.log("Summary:");
|
||||
console.log(`User: ${user.email}`);
|
||||
@@ -121,6 +86,76 @@ async function seed() {
|
||||
console.log(` - realtime-streams: TRIGGER_PROJECT_REF=proj_klxlzjnzxmbgiwuuwhvb`);
|
||||
}
|
||||
|
||||
async function createBatchLimitOrgs(user: User) {
|
||||
const org1 = await findOrCreateOrganization("batch-limit-org-1", user, {
|
||||
batchQueueConcurrencyConfig: { processingConcurrency: 1 },
|
||||
});
|
||||
const org2 = await findOrCreateOrganization("batch-limit-org-2", user, {
|
||||
batchQueueConcurrencyConfig: { processingConcurrency: 5 },
|
||||
});
|
||||
const org3 = await findOrCreateOrganization("batch-limit-org-3", user, {
|
||||
batchQueueConcurrencyConfig: { processingConcurrency: 10 },
|
||||
});
|
||||
|
||||
// Create 3 projects in each organization
|
||||
const org1Project1 = await findOrCreateProject("batch-limit-project-1", org1, user.id);
|
||||
const org1Project2 = await findOrCreateProject("batch-limit-project-2", org1, user.id);
|
||||
const org1Project3 = await findOrCreateProject("batch-limit-project-3", org1, user.id);
|
||||
|
||||
const org2Project1 = await findOrCreateProject("batch-limit-project-1", org2, user.id);
|
||||
const org2Project2 = await findOrCreateProject("batch-limit-project-2", org2, user.id);
|
||||
const org2Project3 = await findOrCreateProject("batch-limit-project-3", org2, user.id);
|
||||
|
||||
const org3Project1 = await findOrCreateProject("batch-limit-project-1", org3, user.id);
|
||||
const org3Project2 = await findOrCreateProject("batch-limit-project-2", org3, user.id);
|
||||
const org3Project3 = await findOrCreateProject("batch-limit-project-3", org3, user.id);
|
||||
|
||||
console.log("tenants.json");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
apiUrl: "http://localhost:3030",
|
||||
tenants: [
|
||||
{
|
||||
id: org1Project1.project.externalRef,
|
||||
secretKey: org1Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org1Project2.project.externalRef,
|
||||
secretKey: org1Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org1Project3.project.externalRef,
|
||||
secretKey: org1Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org2Project1.project.externalRef,
|
||||
secretKey: org2Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org2Project2.project.externalRef,
|
||||
secretKey: org2Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org2Project3.project.externalRef,
|
||||
secretKey: org2Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org3Project1.project.externalRef,
|
||||
secretKey: org3Project1.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org3Project2.project.externalRef,
|
||||
secretKey: org3Project2.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
{
|
||||
id: org3Project3.project.externalRef,
|
||||
secretKey: org3Project3.environments.find((e) => e.type === "DEVELOPMENT")?.apiKey,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seed failed:");
|
||||
@@ -130,3 +165,87 @@ seed()
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function findOrCreateOrganization(
|
||||
title: string,
|
||||
user: User,
|
||||
updates?: Prisma.OrganizationUpdateInput
|
||||
) {
|
||||
let organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
title: title,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(`Creating organization: ${title}...`);
|
||||
organization = await createOrganization({
|
||||
title: title,
|
||||
userId: user.id,
|
||||
companySize: "1-10",
|
||||
});
|
||||
}
|
||||
|
||||
if (updates) {
|
||||
organization = await prisma.organization.update({
|
||||
where: { id: organization.id },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
async function findOrCreateProject(
|
||||
name: string,
|
||||
organization: Organization,
|
||||
userId: string,
|
||||
externalRef?: string
|
||||
) {
|
||||
let project = await prisma.project.findFirst({
|
||||
where: {
|
||||
name,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
console.log(`Creating project: ${name}...`);
|
||||
project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name,
|
||||
userId,
|
||||
version: "v3",
|
||||
});
|
||||
|
||||
if (externalRef) {
|
||||
project = await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { externalRef },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Project ready: ${project.name} (${project.externalRef})`);
|
||||
|
||||
// list environments for this project
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: {
|
||||
slug: true,
|
||||
type: true,
|
||||
apiKey: true,
|
||||
},
|
||||
});
|
||||
console.log(` Environments for ${project.name}:`);
|
||||
for (const env of environments) {
|
||||
console.log(` - ${env.type.toLowerCase()} (${env.slug}): ${env.apiKey}`);
|
||||
}
|
||||
|
||||
return { project, environments };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the db prisma client - needs to be before other imports
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getEntitlement: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { RunEngine } from "@internal/run-engine";
|
||||
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
StreamBatchItemsService,
|
||||
createNdjsonParserStream,
|
||||
streamToAsyncIterable,
|
||||
} from "../../app/runEngine/services/streamBatchItems.server";
|
||||
import { ServiceValidationError } from "../../app/v3/services/baseService.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
|
||||
|
||||
describe("StreamBatchItemsService", () => {
|
||||
/**
|
||||
* Helper to create a batch directly in the database
|
||||
*/
|
||||
async function createBatch(
|
||||
prisma: PrismaClient,
|
||||
environmentId: string,
|
||||
options: {
|
||||
runCount: number;
|
||||
status?: "PENDING" | "PROCESSING" | "COMPLETED" | "ABORTED";
|
||||
sealed?: boolean;
|
||||
}
|
||||
) {
|
||||
const { id, friendlyId } = BatchId.generate();
|
||||
|
||||
const batch = await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
status: options.status ?? "PENDING",
|
||||
runCount: options.runCount,
|
||||
expectedCount: options.runCount,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2",
|
||||
sealed: options.sealed ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an async iterable from items
|
||||
*/
|
||||
async function* itemsToAsyncIterable(
|
||||
items: Array<{ task: string; payload: string; index: number }>
|
||||
) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
containerTest(
|
||||
"should seal batch successfully when no race condition",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
disabled: true,
|
||||
},
|
||||
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.0005,
|
||||
},
|
||||
batchQueue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Create a batch
|
||||
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
||||
runCount: 2,
|
||||
status: "PENDING",
|
||||
sealed: false,
|
||||
});
|
||||
|
||||
// Initialize the batch in Redis
|
||||
await engine.initializeBatch({
|
||||
batchId: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
environmentType: authenticatedEnvironment.type,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
runCount: 2,
|
||||
processingConcurrency: 10,
|
||||
});
|
||||
|
||||
// Enqueue items directly to Redis (bypassing the service's item processing)
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item1" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item2" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
|
||||
// Create service with our test engine and prisma
|
||||
const service = new StreamBatchItemsService({
|
||||
prisma,
|
||||
engine,
|
||||
});
|
||||
|
||||
// Create an empty items iterator since items are already enqueued
|
||||
const items = itemsToAsyncIterable([]);
|
||||
|
||||
const result = await service.call(authenticatedEnvironment, batch.friendlyId, items, {
|
||||
maxItemBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(result.id).toBe(batch.friendlyId);
|
||||
|
||||
// Verify the batch is sealed in the database
|
||||
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
||||
where: { id: batch.id },
|
||||
});
|
||||
|
||||
expect(updatedBatch?.sealed).toBe(true);
|
||||
expect(updatedBatch?.status).toBe("PROCESSING");
|
||||
expect(updatedBatch?.sealedAt).toBeDefined();
|
||||
expect(updatedBatch?.processingStartedAt).toBeDefined();
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should handle race condition when batch already sealed by another request",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
disabled: true,
|
||||
},
|
||||
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.0005,
|
||||
},
|
||||
batchQueue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Create a batch that is already sealed and PROCESSING (simulating another request won the race)
|
||||
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
||||
runCount: 2,
|
||||
status: "PROCESSING",
|
||||
sealed: true,
|
||||
});
|
||||
|
||||
// Initialize the batch in Redis with full count
|
||||
await engine.initializeBatch({
|
||||
batchId: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
environmentType: authenticatedEnvironment.type,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
runCount: 2,
|
||||
processingConcurrency: 10,
|
||||
});
|
||||
|
||||
// Enqueue items directly
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item1" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item2" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
|
||||
const service = new StreamBatchItemsService({
|
||||
prisma,
|
||||
engine,
|
||||
});
|
||||
|
||||
// This should fail because the batch is already sealed
|
||||
await expect(
|
||||
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
||||
maxItemBytes: 1024 * 1024,
|
||||
})
|
||||
).rejects.toThrow(ServiceValidationError);
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should return sealed=true when concurrent request already sealed the batch during seal attempt",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
disabled: true,
|
||||
},
|
||||
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.0005,
|
||||
},
|
||||
batchQueue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Create a batch in PENDING state
|
||||
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
||||
runCount: 2,
|
||||
status: "PENDING",
|
||||
sealed: false,
|
||||
});
|
||||
|
||||
// Initialize the batch in Redis
|
||||
await engine.initializeBatch({
|
||||
batchId: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
environmentType: authenticatedEnvironment.type,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
runCount: 2,
|
||||
processingConcurrency: 10,
|
||||
});
|
||||
|
||||
// Enqueue items
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item1" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item2" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
|
||||
// Create a custom prisma client that simulates a race condition:
|
||||
// When updateMany is called on batchTaskRun, it returns count: 0 (as if another request beat us)
|
||||
// but the subsequent findUnique shows the batch is sealed and PROCESSING
|
||||
const racingPrisma = {
|
||||
...prisma,
|
||||
batchTaskRun: {
|
||||
...prisma.batchTaskRun,
|
||||
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
||||
updateMany: async () => {
|
||||
// Simulate another request winning the race - seal the batch first
|
||||
await prisma.batchTaskRun.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
sealed: true,
|
||||
sealedAt: new Date(),
|
||||
status: "PROCESSING",
|
||||
processingStartedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Return 0 as if the conditional update failed
|
||||
return { count: 0 };
|
||||
},
|
||||
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const service = new StreamBatchItemsService({
|
||||
prisma: racingPrisma,
|
||||
engine,
|
||||
});
|
||||
|
||||
// Call the service - it should detect the race and return success since batch is sealed
|
||||
const result = await service.call(
|
||||
authenticatedEnvironment,
|
||||
batch.friendlyId,
|
||||
itemsToAsyncIterable([]),
|
||||
{
|
||||
maxItemBytes: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
// Should return sealed=true because the batch was sealed (by the "other" request)
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(result.id).toBe(batch.friendlyId);
|
||||
|
||||
// Verify the batch is sealed in the database
|
||||
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
||||
where: { id: batch.id },
|
||||
});
|
||||
|
||||
expect(updatedBatch?.sealed).toBe(true);
|
||||
expect(updatedBatch?.status).toBe("PROCESSING");
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should throw error when race condition leaves batch in unexpected state",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
disabled: true,
|
||||
},
|
||||
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.0005,
|
||||
},
|
||||
batchQueue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Create a batch in PENDING state
|
||||
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
||||
runCount: 2,
|
||||
status: "PENDING",
|
||||
sealed: false,
|
||||
});
|
||||
|
||||
// Initialize the batch in Redis
|
||||
await engine.initializeBatch({
|
||||
batchId: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
environmentType: authenticatedEnvironment.type,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
runCount: 2,
|
||||
processingConcurrency: 10,
|
||||
});
|
||||
|
||||
// Enqueue items
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item1" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item2" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
|
||||
// Create a custom prisma client that simulates a race condition where
|
||||
// the batch ends up in an unexpected state (ABORTED instead of PROCESSING)
|
||||
const racingPrisma = {
|
||||
...prisma,
|
||||
batchTaskRun: {
|
||||
...prisma.batchTaskRun,
|
||||
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
||||
updateMany: async () => {
|
||||
// Simulate the batch being aborted by another process
|
||||
await prisma.batchTaskRun.update({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
sealed: true,
|
||||
status: "ABORTED",
|
||||
},
|
||||
});
|
||||
// Return 0 as if the conditional update failed
|
||||
return { count: 0 };
|
||||
},
|
||||
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const service = new StreamBatchItemsService({
|
||||
prisma: racingPrisma,
|
||||
engine,
|
||||
});
|
||||
|
||||
// Call the service - it should throw because the batch is in an unexpected state
|
||||
await expect(
|
||||
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
||||
maxItemBytes: 1024 * 1024,
|
||||
})
|
||||
).rejects.toThrow(/unexpected state/);
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should return sealed=false when item count does not match",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
disabled: true,
|
||||
},
|
||||
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.0005,
|
||||
},
|
||||
batchQueue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
|
||||
// Create a batch expecting 3 items
|
||||
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
||||
runCount: 3,
|
||||
status: "PENDING",
|
||||
sealed: false,
|
||||
});
|
||||
|
||||
// Initialize the batch in Redis
|
||||
await engine.initializeBatch({
|
||||
batchId: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
environmentType: authenticatedEnvironment.type,
|
||||
organizationId: authenticatedEnvironment.organizationId,
|
||||
projectId: authenticatedEnvironment.projectId,
|
||||
runCount: 3,
|
||||
processingConcurrency: 10,
|
||||
});
|
||||
|
||||
// Only enqueue 2 items (1 short of expected)
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item1" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
||||
task: "test-task",
|
||||
payload: JSON.stringify({ data: "item2" }),
|
||||
payloadType: "application/json",
|
||||
});
|
||||
|
||||
const service = new StreamBatchItemsService({
|
||||
prisma,
|
||||
engine,
|
||||
});
|
||||
|
||||
const result = await service.call(
|
||||
authenticatedEnvironment,
|
||||
batch.friendlyId,
|
||||
itemsToAsyncIterable([]),
|
||||
{
|
||||
maxItemBytes: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
// Should return sealed=false because item count doesn't match
|
||||
expect(result.sealed).toBe(false);
|
||||
expect(result.enqueuedCount).toBe(2);
|
||||
expect(result.expectedCount).toBe(3);
|
||||
|
||||
// Verify the batch is NOT sealed in the database
|
||||
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
||||
where: { id: batch.id },
|
||||
});
|
||||
|
||||
expect(updatedBatch?.sealed).toBe(false);
|
||||
expect(updatedBatch?.status).toBe("PENDING");
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("createNdjsonParserStream", () => {
|
||||
/**
|
||||
* Helper to collect all items from a ReadableStream
|
||||
*/
|
||||
async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of streamToAsyncIterable(stream)) {
|
||||
results.push(item);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a ReadableStream from an array of Uint8Array chunks
|
||||
*/
|
||||
function chunksToStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let index = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("should parse basic NDJSON correctly", async () => {
|
||||
const ndjson = '{"name":"alice"}\n{"name":"bob"}\n{"name":"charlie"}\n';
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]);
|
||||
});
|
||||
|
||||
it("should handle NDJSON without trailing newline", async () => {
|
||||
const ndjson = '{"id":1}\n{"id":2}';
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
});
|
||||
|
||||
it("should skip empty lines", async () => {
|
||||
const ndjson = '{"a":1}\n\n{"b":2}\n \n{"c":3}\n';
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
|
||||
});
|
||||
|
||||
it("should handle chunks split mid-line", async () => {
|
||||
// Split '{"split":"value"}\n' across multiple chunks
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [encoder.encode('{"spl'), encoder.encode('it":"va'), encoder.encode('lue"}\n')];
|
||||
const stream = chunksToStream(chunks);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ split: "value" }]);
|
||||
});
|
||||
|
||||
it("should handle multibyte UTF-8 characters split across chunks", async () => {
|
||||
// Test with emoji and other multibyte characters
|
||||
// The emoji "😀" is 4 bytes: 0xF0 0x9F 0x98 0x80
|
||||
const json = '{"emoji":"😀"}\n';
|
||||
const fullBytes = new TextEncoder().encode(json);
|
||||
|
||||
// Split in the middle of the emoji (between byte 2 and 3 of the 4-byte sequence)
|
||||
// Find where the emoji starts
|
||||
const emojiStart = fullBytes.indexOf(0xf0);
|
||||
expect(emojiStart).toBeGreaterThan(0);
|
||||
|
||||
// Split after first 2 bytes of the emoji
|
||||
const chunk1 = fullBytes.slice(0, emojiStart + 2);
|
||||
const chunk2 = fullBytes.slice(emojiStart + 2);
|
||||
|
||||
const stream = chunksToStream([chunk1, chunk2]);
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ emoji: "😀" }]);
|
||||
});
|
||||
|
||||
it("should handle multiple multibyte characters across chunks", async () => {
|
||||
// Japanese text: "こんにちは" (each hiragana is 3 bytes in UTF-8)
|
||||
const json = '{"greeting":"こんにちは"}\n';
|
||||
const fullBytes = new TextEncoder().encode(json);
|
||||
|
||||
// Split into many small chunks to stress test UTF-8 handling
|
||||
const chunkSize = 3; // Deliberately misaligned with UTF-8 boundaries
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (let i = 0; i < fullBytes.length; i += chunkSize) {
|
||||
chunks.push(fullBytes.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
const stream = chunksToStream(chunks);
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ greeting: "こんにちは" }]);
|
||||
});
|
||||
|
||||
it("should reject lines exceeding maxItemBytes", async () => {
|
||||
const maxBytes = 50;
|
||||
// Create a line that exceeds the limit
|
||||
const largeJson = JSON.stringify({ data: "x".repeat(100) }) + "\n";
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(largeJson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(maxBytes);
|
||||
|
||||
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(/exceeds maximum size/);
|
||||
});
|
||||
|
||||
it("should reject unbounded accumulation without newlines", async () => {
|
||||
const maxBytes = 50;
|
||||
// Send data without any newlines that exceeds the buffer limit
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [
|
||||
encoder.encode('{"start":"'),
|
||||
encoder.encode("x".repeat(60)), // This will push buffer over 50 bytes
|
||||
];
|
||||
const stream = chunksToStream(chunks);
|
||||
|
||||
const parser = createNdjsonParserStream(maxBytes);
|
||||
|
||||
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(
|
||||
/exceeds maximum size.*no newline found/
|
||||
);
|
||||
});
|
||||
|
||||
it("should check byte size before decoding to prevent OOM", async () => {
|
||||
// This test verifies that size is checked on raw bytes, not decoded string length
|
||||
// Unicode characters like "🎉" are 4 bytes but 2 UTF-16 code units (string length 2)
|
||||
const maxBytes = 30;
|
||||
|
||||
// Create a line with emojis - each emoji is 4 bytes
|
||||
// {"e":"🎉🎉🎉🎉🎉"} = 5 + 20 (5 emojis * 4 bytes) + 2 = 27 bytes - should pass
|
||||
const smallJson = '{"e":"🎉🎉🎉🎉🎉"}\n';
|
||||
const smallBytes = new TextEncoder().encode(smallJson);
|
||||
expect(smallBytes.length).toBeLessThan(maxBytes);
|
||||
|
||||
// {"e":"🎉🎉🎉🎉🎉🎉🎉"} = 7 emojis * 4 bytes + overhead = 35 bytes - should fail
|
||||
const largeJson = '{"e":"🎉🎉🎉🎉🎉🎉🎉"}\n';
|
||||
const largeBytes = new TextEncoder().encode(largeJson);
|
||||
expect(largeBytes.length).toBeGreaterThan(maxBytes);
|
||||
|
||||
// Small one should succeed
|
||||
const stream1 = chunksToStream([smallBytes]);
|
||||
const parser1 = createNdjsonParserStream(maxBytes);
|
||||
const results1 = await collectStream(stream1.pipeThrough(parser1));
|
||||
expect(results1).toHaveLength(1);
|
||||
|
||||
// Large one should fail
|
||||
const stream2 = chunksToStream([largeBytes]);
|
||||
const parser2 = createNdjsonParserStream(maxBytes);
|
||||
await expect(collectStream(stream2.pipeThrough(parser2))).rejects.toThrow(/exceeds maximum/);
|
||||
});
|
||||
|
||||
it("should handle final line in flush without trailing newline", async () => {
|
||||
const ndjson = '{"first":1}\n{"second":2}'; // No trailing newline
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ first: 1 }, { second: 2 }]);
|
||||
});
|
||||
|
||||
it("should reject invalid JSON", async () => {
|
||||
const ndjson = '{"valid":true}\n{invalid json}\n';
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
|
||||
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(
|
||||
/Invalid JSON at line 2/
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject invalid UTF-8 sequences", async () => {
|
||||
// Invalid UTF-8: 0xFF is never valid in UTF-8
|
||||
const invalidBytes = new Uint8Array([
|
||||
0x7b,
|
||||
0x22,
|
||||
0x78,
|
||||
0x22,
|
||||
0x3a,
|
||||
0xff,
|
||||
0x7d,
|
||||
0x0a, // {"x":�}\n with invalid byte
|
||||
]);
|
||||
const stream = chunksToStream([invalidBytes]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
|
||||
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(/Invalid UTF-8/);
|
||||
});
|
||||
|
||||
it("should handle many small chunks efficiently", async () => {
|
||||
// Simulate streaming byte-by-byte
|
||||
const json = '{"test":"value"}\n';
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
const chunks = Array.from(bytes).map((b) => new Uint8Array([b]));
|
||||
|
||||
const stream = chunksToStream(chunks);
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ test: "value" }]);
|
||||
});
|
||||
|
||||
it("should handle multiple lines per chunk", async () => {
|
||||
const ndjson = '{"a":1}\n{"b":2}\n{"c":3}\n{"d":4}\n{"e":5}\n';
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(ndjson)]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }]);
|
||||
});
|
||||
|
||||
it("should handle empty stream", async () => {
|
||||
const stream = chunksToStream([]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle stream with only whitespace", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = chunksToStream([encoder.encode(" \n\n \n")]);
|
||||
|
||||
const parser = createNdjsonParserStream(1024);
|
||||
const results = await collectStream(stream.pipeThrough(parser));
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
MaxAttemptsValidationParams,
|
||||
ParentRunValidationParams,
|
||||
PayloadProcessor,
|
||||
RunNumberIncrementer,
|
||||
TagValidationParams,
|
||||
TracedEventSpan,
|
||||
TraceEventConcern,
|
||||
@@ -43,15 +42,6 @@ import { setTimeout } from "node:timers/promises";
|
||||
|
||||
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
|
||||
|
||||
class MockRunNumberIncrementer implements RunNumberIncrementer {
|
||||
async incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
return await callback(1);
|
||||
}
|
||||
}
|
||||
|
||||
class MockPayloadProcessor implements PayloadProcessor {
|
||||
async process(request: TriggerTaskRequest): Promise<IOPacket> {
|
||||
return {
|
||||
@@ -90,6 +80,7 @@ class MockTraceEventConcern implements TraceEventConcern {
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
@@ -114,6 +105,32 @@ class MockTraceEventConcern implements TraceEventConcern {
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
}
|
||||
|
||||
async traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await callback(
|
||||
{
|
||||
traceId: "test",
|
||||
spanId: "test",
|
||||
traceContext: {},
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
@@ -192,7 +209,6 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
runNumberIncrementer: new MockRunNumberIncrementer(),
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
@@ -283,7 +299,6 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
runNumberIncrementer: new MockRunNumberIncrementer(),
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
@@ -463,7 +478,6 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
runNumberIncrementer: new MockRunNumberIncrementer(),
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
@@ -647,7 +661,6 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
runNumberIncrementer: new MockRunNumberIncrementer(),
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
@@ -731,4 +744,432 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should preserve runFriendlyId across retries when RunDuplicateIdempotencyKeyError is thrown",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
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.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
logLevel: "debug",
|
||||
});
|
||||
|
||||
const parentTask = "parent-task";
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
// Create background worker
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, [parentTask, taskIdentifier]);
|
||||
|
||||
// Create parent runs and start their attempts (required for resumeParentOnCompletion)
|
||||
const parentRun1 = await engine.trigger(
|
||||
{
|
||||
number: 1,
|
||||
friendlyId: "run_p1",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier: parentTask,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12345",
|
||||
spanId: "s12345",
|
||||
queue: `task/${parentTask}`,
|
||||
isTest: false,
|
||||
tags: [],
|
||||
workerQueue: "main",
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
await setTimeout(500);
|
||||
const dequeued = await engine.dequeueFromWorkerQueue({
|
||||
consumerId: "test_12345",
|
||||
workerQueue: "main",
|
||||
});
|
||||
await engine.startRunAttempt({
|
||||
runId: parentRun1.id,
|
||||
snapshotId: dequeued[0].snapshot.id,
|
||||
});
|
||||
|
||||
const parentRun2 = await engine.trigger(
|
||||
{
|
||||
number: 2,
|
||||
friendlyId: "run_p2",
|
||||
environment: authenticatedEnvironment,
|
||||
taskIdentifier: parentTask,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
context: {},
|
||||
traceContext: {},
|
||||
traceId: "t12346",
|
||||
spanId: "s12346",
|
||||
queue: `task/${parentTask}`,
|
||||
isTest: false,
|
||||
tags: [],
|
||||
workerQueue: "main",
|
||||
},
|
||||
prisma
|
||||
);
|
||||
|
||||
await setTimeout(500);
|
||||
const dequeued2 = await engine.dequeueFromWorkerQueue({
|
||||
consumerId: "test_12345",
|
||||
workerQueue: "main",
|
||||
});
|
||||
await engine.startRunAttempt({
|
||||
runId: parentRun2.id,
|
||||
snapshotId: dequeued2[0].snapshot.id,
|
||||
});
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine);
|
||||
const idempotencyKeyConcern = new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
);
|
||||
|
||||
const triggerRacepointSystem = new MockTriggerRacepointSystem();
|
||||
|
||||
// Track all friendlyIds passed to the payload processor
|
||||
const processedFriendlyIds: string[] = [];
|
||||
class TrackingPayloadProcessor implements PayloadProcessor {
|
||||
async process(request: TriggerTaskRequest): Promise<IOPacket> {
|
||||
processedFriendlyIds.push(request.friendlyId);
|
||||
return {
|
||||
data: JSON.stringify(request.body.payload),
|
||||
dataType: "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new TrackingPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1, // 1MB
|
||||
triggerRacepointSystem,
|
||||
});
|
||||
|
||||
const idempotencyKey = "test-preserve-friendly-id";
|
||||
const racepoint = triggerRacepointSystem.registerRacepoint("idempotencyKey", idempotencyKey);
|
||||
|
||||
// Trigger two concurrent requests with same idempotency key
|
||||
// One will succeed, one will fail with RunDuplicateIdempotencyKeyError and retry
|
||||
const childTriggerPromise1 = triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: {
|
||||
payload: { test: "test1" },
|
||||
options: {
|
||||
idempotencyKey,
|
||||
parentRunId: parentRun1.friendlyId,
|
||||
resumeParentOnCompletion: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const childTriggerPromise2 = triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: {
|
||||
payload: { test: "test2" },
|
||||
options: {
|
||||
idempotencyKey,
|
||||
parentRunId: parentRun2.friendlyId,
|
||||
resumeParentOnCompletion: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setTimeout(500);
|
||||
|
||||
// Resolve the racepoint to allow both requests to proceed
|
||||
racepoint.resolve();
|
||||
|
||||
const result1 = await childTriggerPromise1;
|
||||
const result2 = await childTriggerPromise2;
|
||||
|
||||
// Both should return the same run (one created, one cached)
|
||||
expect(result1).toBeDefined();
|
||||
expect(result2).toBeDefined();
|
||||
expect(result1?.run.friendlyId).toBe(result2?.run.friendlyId);
|
||||
|
||||
// The key assertion: When a retry happens due to RunDuplicateIdempotencyKeyError,
|
||||
// the same friendlyId should be used. We expect exactly 2 calls to payloadProcessor
|
||||
// (one for each concurrent request), not 3 (which would indicate a new friendlyId on retry)
|
||||
// Since the retry returns early from the idempotency cache, payloadProcessor is not called again.
|
||||
expect(processedFriendlyIds.length).toBe(2);
|
||||
|
||||
// Verify that we have exactly 2 unique friendlyIds (one per original request)
|
||||
const uniqueFriendlyIds = new Set(processedFriendlyIds);
|
||||
expect(uniqueFriendlyIds.size).toBe(2);
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should reject invalid debounce.delay when no explicit delay is provided",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
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.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine);
|
||||
const idempotencyKeyConcern = new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
);
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Invalid debounce.delay format (ms not supported)
|
||||
await expect(
|
||||
triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: {
|
||||
payload: { test: "test" },
|
||||
options: {
|
||||
debounce: {
|
||||
key: "test-key",
|
||||
delay: "300ms", // Invalid - ms not supported
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Debounce requires a valid delay duration");
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should reject invalid debounce.delay even when explicit delay is valid",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
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.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine);
|
||||
const idempotencyKeyConcern = new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
);
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Valid explicit delay but invalid debounce.delay
|
||||
// This is the bug case: the explicit delay passes validation,
|
||||
// but debounce.delay would fail later when rescheduling
|
||||
await expect(
|
||||
triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: {
|
||||
payload: { test: "test" },
|
||||
options: {
|
||||
delay: "5m", // Valid explicit delay
|
||||
debounce: {
|
||||
key: "test-key",
|
||||
delay: "invalid-delay", // Invalid debounce delay
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Invalid debounce delay");
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"should accept valid debounce.delay formats",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
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.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
|
||||
await setupBackgroundWorker(engine, authenticatedEnvironment, taskIdentifier);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine);
|
||||
const idempotencyKeyConcern = new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
);
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern,
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Valid debounce.delay format
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: authenticatedEnvironment,
|
||||
body: {
|
||||
payload: { test: "test" },
|
||||
options: {
|
||||
debounce: {
|
||||
key: "test-key",
|
||||
delay: "5s", // Valid format
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.run.friendlyId).toBeDefined();
|
||||
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "Processing Health",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m]))",
|
||||
"legendFormat": "Items/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Items Processed Rate",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_failed_total[5m]))",
|
||||
"legendFormat": "Failed/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Items Failed Rate",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "yellow", "value": 0.9 },
|
||||
{ "color": "green", "value": 0.95 }
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) / (sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) + sum(rate(triggerdotdev_batch_queue_items_failed_total[5m])))",
|
||||
"legendFormat": "Success Rate",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Success Rate",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_batches_completed_total[5m]))",
|
||||
"legendFormat": "Batches/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Batches Completed Rate",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_processed_total[5m])) by (envId)",
|
||||
"legendFormat": "Processed - {{envId}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_failed_total[5m])) by (envId)",
|
||||
"legendFormat": "Failed - {{envId}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Items Processed/Failed by Environment",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_batches_enqueued_total[5m]))",
|
||||
"legendFormat": "Enqueued",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_batches_completed_total[5m]))",
|
||||
"legendFormat": "Completed",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Batches Enqueued vs Completed",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
|
||||
"id": 8,
|
||||
"panels": [],
|
||||
"title": "Latency",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_item_queue_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Item Queue Time (time from enqueue to processing)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_batch_processing_duration_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Batch Processing Duration (creation to completion)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 24 },
|
||||
"id": 11,
|
||||
"panels": [],
|
||||
"title": "Queue Depth",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 25 },
|
||||
"id": 12,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(triggerdotdev_batch_queue_inflight_count_messages)",
|
||||
"legendFormat": "In-flight",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Messages In-Flight",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 25 },
|
||||
"id": 13,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(triggerdotdev_batch_queue_master_queue_length_queues)",
|
||||
"legendFormat": "Active Queues",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Queues in Master Queue",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 10 }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 25 },
|
||||
"id": 14,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(triggerdotdev_batch_queue_dlq_length_messages)",
|
||||
"legendFormat": "DLQ Size",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Dead Letter Queue (should be 0)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 33 },
|
||||
"id": 15,
|
||||
"panels": [],
|
||||
"title": "FairQueue Internals",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
|
||||
"id": 16,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_messages_completed_total[5m]))",
|
||||
"legendFormat": "Completed",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_messages_failed_total[5m]))",
|
||||
"legendFormat": "Failed",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_messages_retried_total[5m]))",
|
||||
"legendFormat": "Retried",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "FairQueue Message Processing",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ms"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
|
||||
"id": 17,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.50, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(triggerdotdev_batch_queue_message_processing_time_milliseconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "FairQueue Message Processing Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 42 },
|
||||
"id": 18,
|
||||
"panels": [],
|
||||
"title": "Items Enqueued",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 43 },
|
||||
"id": 19,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(triggerdotdev_batch_queue_items_enqueued_total[5m])) by (envId)",
|
||||
"legendFormat": "Enqueued - {{envId}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Items Enqueued Rate by Environment",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "10s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["trigger.dev", "batch-queue"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Batch Queue Metrics",
|
||||
"uid": "batch-queue-metrics",
|
||||
"version": 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Grafana dashboard provisioning
|
||||
# Automatically loads dashboard JSON files from the dashboards folder
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: "Trigger.dev Dashboards"
|
||||
orgId: 1
|
||||
folder: "Trigger.dev"
|
||||
folderUid: "triggerdotdev"
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "Event Loop Health",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.7 },
|
||||
{ "color": "red", "value": 0.9 }
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_event_loop_utilization_ratio",
|
||||
"legendFormat": "ELU",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Utilization",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.1 },
|
||||
{ "color": "red", "value": 0.5 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_p99_seconds",
|
||||
"legendFormat": "p99 Lag",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Lag (p99)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.05 },
|
||||
{ "color": "red", "value": 0.1 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_mean_seconds",
|
||||
"legendFormat": "Mean Lag",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Lag (Mean)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_uv_threadpool_size_threads",
|
||||
"legendFormat": "UV Threadpool",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "UV Threadpool Size",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_event_loop_utilization_ratio",
|
||||
"legendFormat": "Event Loop Utilization",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Utilization Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_p50_seconds",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_p90_seconds",
|
||||
"legendFormat": "p90",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_p99_seconds",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
},
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_eventloop_lag_max_seconds",
|
||||
"legendFormat": "max",
|
||||
"refId": "D"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Lag Percentiles",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
|
||||
"id": 8,
|
||||
"panels": [],
|
||||
"title": "Handles & Requests",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_active_handles_total_handles",
|
||||
"legendFormat": "Total Handles",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Handles (Total)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_active_requests_total_requests",
|
||||
"legendFormat": "Total Requests",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Requests (Total)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||
"id": 11,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "triggerdotdev_nodejs_active_handles_handles",
|
||||
"legendFormat": "{{type}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Handles by Type",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "10s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["trigger.dev", "nodejs", "runtime"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Node.js Runtime",
|
||||
"uid": "nodejs-runtime",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Grafana datasource provisioning
|
||||
# Automatically configures Prometheus as the default datasource
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
httpMethod: POST
|
||||
manageAlerts: true
|
||||
prometheusType: Prometheus
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# OpenTelemetry Collector configuration for local development
|
||||
# Receives OTLP metrics from the webapp and exposes them in Prometheus format
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
processors:
|
||||
batch:
|
||||
timeout: 10s
|
||||
send_batch_size: 1024
|
||||
|
||||
exporters:
|
||||
prometheus:
|
||||
endpoint: 0.0.0.0:8889
|
||||
namespace: triggerdotdev
|
||||
const_labels:
|
||||
source: otel_collector
|
||||
resource_to_telemetry_conversion:
|
||||
enabled: true
|
||||
|
||||
# Debug exporter for troubleshooting (optional, uncomment to enable)
|
||||
# debug:
|
||||
# verbosity: detailed
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [prometheus]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Prometheus configuration for local development
|
||||
# Scrapes metrics from OTEL Collector and the webapp /metrics endpoint
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
# Scrape OpenTelemetry Collector's Prometheus exporter
|
||||
# This includes all OTel metrics (batch queue, fair queue, etc.)
|
||||
- job_name: "otel-collector"
|
||||
static_configs:
|
||||
- targets: ["otel-collector:8889"]
|
||||
metrics_path: /metrics
|
||||
|
||||
# Scrape webapp's /metrics endpoint
|
||||
# This includes Prisma metrics and prom-client default metrics
|
||||
# Note: The webapp runs on host machine, not in Docker network
|
||||
# Use host.docker.internal on Mac/Windows, or the actual host IP on Linux
|
||||
- job_name: "webapp"
|
||||
static_configs:
|
||||
- targets: ["host.docker.internal:3030"]
|
||||
metrics_path: /metrics
|
||||
# Uncomment if you set TRIGGER_METRICS_AUTH_PASSWORD
|
||||
# authorization:
|
||||
# type: Bearer
|
||||
# credentials: your-password-here
|
||||
|
||||
# Prometheus self-monitoring
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
|
||||
+50
-12
@@ -6,6 +6,8 @@ volumes:
|
||||
redis-data:
|
||||
clickhouse-data:
|
||||
clickhouse-logs:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
@@ -164,15 +166,51 @@ services:
|
||||
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./config/certs:/etc/nginx/certs:ro
|
||||
|
||||
# otel-collector:
|
||||
# container_name: otel-collector
|
||||
# image: otel/opentelemetry-collector-contrib:latest
|
||||
# restart: always
|
||||
# command: ["--config", "/etc/otel-collector-config.yaml"]
|
||||
# volumes:
|
||||
# - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
# ports:
|
||||
# - "55680:55680"
|
||||
# - "55681:55681"
|
||||
# - "4317:4317" # OTLP gRPC receiver
|
||||
# - "4318:4318" # OTLP http receiver
|
||||
# Observability stack for local development
|
||||
otel-collector:
|
||||
container_name: otel-collector
|
||||
image: otel/opentelemetry-collector-contrib:0.96.0
|
||||
restart: always
|
||||
command: ["--config", "/etc/otel-collector-config.yaml"]
|
||||
volumes:
|
||||
- ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC receiver
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
- "8889:8889" # Prometheus exporter
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
prometheus:
|
||||
container_name: prometheus
|
||||
image: prom/prometheus:v2.54.1
|
||||
restart: always
|
||||
volumes:
|
||||
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- app_network
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--web.enable-lifecycle"
|
||||
|
||||
grafana:
|
||||
container_name: grafana
|
||||
image: grafana/grafana:11.3.0
|
||||
restart: always
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./config/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_USERS_ALLOW_SIGN_UP: false
|
||||
networks:
|
||||
- app_network
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Batch Queue & Fair Queue Metrics Guide
|
||||
|
||||
This document provides a comprehensive breakdown of all metrics emitted by the Batch Queue and Fair Queue systems, including what they mean and how to identify degraded system states.
|
||||
|
||||
## Overview
|
||||
|
||||
The batch queue system consists of two layers:
|
||||
1. **BatchQueue** (`batch_queue.*`) - High-level batch processing metrics
|
||||
2. **FairQueue** (`batch-queue.*`) - Low-level message queue metrics (with `name: "batch-queue"`)
|
||||
|
||||
Both layers emit metrics that together provide full observability into batch processing.
|
||||
|
||||
---
|
||||
|
||||
## BatchQueue Metrics
|
||||
|
||||
These metrics track batch-level operations.
|
||||
|
||||
### Counters
|
||||
|
||||
| Metric | Description | Labels |
|
||||
|--------|-------------|--------|
|
||||
| `batch_queue.batches_enqueued` | Number of batches initialized for processing | `envId`, `itemCount`, `streaming` |
|
||||
| `batch_queue.items_enqueued` | Number of individual batch items enqueued | `envId` |
|
||||
| `batch_queue.items_processed` | Number of batch items successfully processed (turned into runs) | `envId` |
|
||||
| `batch_queue.items_failed` | Number of batch items that failed processing | `envId`, `errorCode` |
|
||||
| `batch_queue.batches_completed` | Number of batches that completed (all items processed) | `envId`, `hasFailures` |
|
||||
|
||||
### Histograms
|
||||
|
||||
| Metric | Description | Unit | Labels |
|
||||
|--------|-------------|------|--------|
|
||||
| `batch_queue.batch_processing_duration` | Time from batch creation to completion | ms | `envId`, `itemCount` |
|
||||
| `batch_queue.item_queue_time` | Time from item enqueue to processing start | ms | `envId` |
|
||||
|
||||
---
|
||||
|
||||
## FairQueue Metrics (batch-queue namespace)
|
||||
|
||||
These metrics track the underlying message queue operations. With the batch queue configuration, they are prefixed with `batch-queue.`.
|
||||
|
||||
### Counters
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| `batch-queue.messages.enqueued` | Number of messages (batch items) added to the queue |
|
||||
| `batch-queue.messages.completed` | Number of messages successfully processed |
|
||||
| `batch-queue.messages.failed` | Number of messages that failed processing |
|
||||
| `batch-queue.messages.retried` | Number of message retry attempts |
|
||||
| `batch-queue.messages.dlq` | Number of messages sent to dead letter queue |
|
||||
|
||||
### Histograms
|
||||
|
||||
| Metric | Description | Unit |
|
||||
|--------|-------------|------|
|
||||
| `batch-queue.message.processing_time` | Time to process a single message | ms |
|
||||
| `batch-queue.message.queue_time` | Time a message spent waiting in queue | ms |
|
||||
|
||||
### Observable Gauges
|
||||
|
||||
| Metric | Description | Labels |
|
||||
|--------|-------------|--------|
|
||||
| `batch-queue.queue.length` | Current number of messages in a queue | `fairqueue.queue_id` |
|
||||
| `batch-queue.master_queue.length` | Number of active queues in the master queue shard | `fairqueue.shard_id` |
|
||||
| `batch-queue.inflight.count` | Number of messages currently being processed | `fairqueue.shard_id` |
|
||||
| `batch-queue.dlq.length` | Number of messages in the dead letter queue | `fairqueue.tenant_id` |
|
||||
|
||||
---
|
||||
|
||||
## Key Relationships
|
||||
|
||||
Understanding how metrics relate helps diagnose issues:
|
||||
|
||||
```
|
||||
batches_enqueued × avg_items_per_batch ≈ items_enqueued
|
||||
items_enqueued = items_processed + items_failed + items_pending
|
||||
batches_completed ≤ batches_enqueued (lag indicates processing backlog)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Degraded State Indicators
|
||||
|
||||
### 🔴 Critical Issues
|
||||
|
||||
#### 1. Processing Stopped
|
||||
**Symptoms:**
|
||||
- `batch_queue.items_processed` rate drops to 0
|
||||
- `batch-queue.inflight.count` is 0
|
||||
- `batch-queue.master_queue.length` is growing
|
||||
|
||||
**Likely Causes:**
|
||||
- Consumer loops crashed
|
||||
- Redis connection issues
|
||||
- All consumers blocked by concurrency limits
|
||||
|
||||
**Actions:**
|
||||
- Check webapp logs for "BatchQueue consumers started" message
|
||||
- Verify Redis connectivity
|
||||
- Check for "Unknown concurrency group" errors
|
||||
|
||||
#### 2. Items Stuck in Queue
|
||||
**Symptoms:**
|
||||
- `batch_queue.item_queue_time` p99 > 60 seconds
|
||||
- `batch-queue.queue.length` growing continuously
|
||||
- `batch-queue.inflight.count` at max capacity
|
||||
|
||||
**Likely Causes:**
|
||||
- Processing is slower than ingestion
|
||||
- Concurrency limits too restrictive
|
||||
- Global rate limiter bottleneck
|
||||
|
||||
**Actions:**
|
||||
- Increase `BATCH_QUEUE_CONSUMER_COUNT`
|
||||
- Review concurrency limits per environment
|
||||
- Check `BATCH_QUEUE_GLOBAL_RATE_LIMIT` setting
|
||||
|
||||
#### 3. High Failure Rate
|
||||
**Symptoms:**
|
||||
- `batch_queue.items_failed` rate > 5% of `items_processed`
|
||||
- `batch-queue.messages.dlq` increasing
|
||||
|
||||
**Likely Causes:**
|
||||
- TriggerTaskService errors
|
||||
- Invalid task identifiers
|
||||
- Downstream service issues
|
||||
|
||||
**Actions:**
|
||||
- Check `errorCode` label distribution on `items_failed`
|
||||
- Review batch error records in database
|
||||
- Check TriggerTaskService logs
|
||||
|
||||
### 🟡 Warning Signs
|
||||
|
||||
#### 4. Growing Backlog
|
||||
**Symptoms:**
|
||||
- `batch_queue.batches_enqueued` - `batch_queue.batches_completed` is increasing over time
|
||||
- `batch-queue.master_queue.length` trending upward
|
||||
|
||||
**Likely Causes:**
|
||||
- Sustained high load
|
||||
- Processing capacity insufficient
|
||||
- Specific tenants monopolizing resources
|
||||
|
||||
**Actions:**
|
||||
- Monitor DRR deficit distribution across tenants
|
||||
- Consider scaling consumers
|
||||
- Review per-tenant concurrency settings
|
||||
|
||||
#### 5. Uneven Tenant Processing
|
||||
**Symptoms:**
|
||||
- Some `envId` labels show much higher `item_queue_time` than others
|
||||
- DRR logs show "tenants blocked by concurrency" frequently
|
||||
|
||||
**Likely Causes:**
|
||||
- Concurrency limits too low for high-volume tenants
|
||||
- DRR quantum/maxDeficit misconfigured
|
||||
|
||||
**Actions:**
|
||||
- Review `BATCH_CONCURRENCY_*` environment settings
|
||||
- Adjust DRR parameters if needed
|
||||
|
||||
#### 6. Rate Limit Impact
|
||||
**Symptoms:**
|
||||
- `batch_queue.item_queue_time` has periodic spikes
|
||||
- Logs show "Global rate limit reached, waiting"
|
||||
|
||||
**Likely Causes:**
|
||||
- `BATCH_QUEUE_GLOBAL_RATE_LIMIT` is set too low
|
||||
|
||||
**Actions:**
|
||||
- Increase global rate limit if system can handle more throughput
|
||||
- Or accept as intentional throttling
|
||||
|
||||
---
|
||||
|
||||
## Recommended Dashboards
|
||||
|
||||
### Processing Health
|
||||
```
|
||||
# Throughput
|
||||
rate(batch_queue_items_processed_total[5m])
|
||||
rate(batch_queue_items_failed_total[5m])
|
||||
|
||||
# Success Rate
|
||||
rate(batch_queue_items_processed_total[5m]) /
|
||||
(rate(batch_queue_items_processed_total[5m]) + rate(batch_queue_items_failed_total[5m]))
|
||||
|
||||
# Batch Completion Rate
|
||||
rate(batch_queue_batches_completed_total[5m]) / rate(batch_queue_batches_enqueued_total[5m])
|
||||
```
|
||||
|
||||
### Latency
|
||||
```
|
||||
# Item Queue Time (p50, p95, p99)
|
||||
histogram_quantile(0.50, rate(batch_queue_item_queue_time_bucket[5m]))
|
||||
histogram_quantile(0.95, rate(batch_queue_item_queue_time_bucket[5m]))
|
||||
histogram_quantile(0.99, rate(batch_queue_item_queue_time_bucket[5m]))
|
||||
|
||||
# Batch Processing Duration
|
||||
histogram_quantile(0.95, rate(batch_queue_batch_processing_duration_bucket[5m]))
|
||||
```
|
||||
|
||||
### Queue Depth
|
||||
```
|
||||
# Current backlog
|
||||
batch_queue_master_queue_length
|
||||
batch_queue_inflight_count
|
||||
|
||||
# DLQ (should be 0)
|
||||
batch_queue_dlq_length
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert Thresholds (Suggested)
|
||||
|
||||
| Condition | Severity | Threshold |
|
||||
|-----------|----------|-----------|
|
||||
| Processing stopped | Critical | `items_processed` rate = 0 for 5min |
|
||||
| High failure rate | Warning | `items_failed` / `items_processed` > 0.05 |
|
||||
| Queue time p99 | Warning | > 30 seconds |
|
||||
| Queue time p99 | Critical | > 120 seconds |
|
||||
| DLQ length | Warning | > 0 |
|
||||
| Batch completion lag | Warning | `batches_enqueued - batches_completed` > 100 |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Affecting Metrics
|
||||
|
||||
| Variable | Impact |
|
||||
|----------|--------|
|
||||
| `BATCH_QUEUE_CONSUMER_COUNT` | More consumers = higher throughput, lower queue time |
|
||||
| `BATCH_QUEUE_CONSUMER_INTERVAL_MS` | Lower = more frequent polling, higher throughput |
|
||||
| `BATCH_QUEUE_GLOBAL_RATE_LIMIT` | Caps max items/sec, increases queue time if too low |
|
||||
| `BATCH_CONCURRENCY_FREE/PAID/ENTERPRISE` | Per-tenant concurrency limits |
|
||||
| `BATCH_QUEUE_DRR_QUANTUM` | Credits per tenant per round (fairness tuning) |
|
||||
| `BATCH_QUEUE_MAX_DEFICIT` | Max accumulated credits (prevents starvation) |
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When investigating batch queue issues:
|
||||
|
||||
1. **Check consumer status**: Look for "BatchQueue consumers started" in logs
|
||||
2. **Check Redis**: Verify connection and inspect keys with prefix `engine:batch-queue:`
|
||||
3. **Check concurrency**: Look for "tenants blocked by concurrency" debug logs
|
||||
4. **Check rate limits**: Look for "Global rate limit reached" debug logs
|
||||
5. **Check DRR state**: Query `batch:drr:deficit` hash in Redis
|
||||
6. **Check batch status**: Query `BatchTaskRun` table for stuck `PROCESSING` batches
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,33 +71,40 @@ export default defineConfig({
|
||||
|
||||
The `syncVercelEnvVars` build extension syncs environment variables from your Vercel project to Trigger.dev.
|
||||
|
||||
<Note>
|
||||
You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
|
||||
in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
|
||||
working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
|
||||
team settings. You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
|
||||
[dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
|
||||
the project with the environment variables you want to sync.
|
||||
</Note>
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting up authentication including team projects">
|
||||
You need to set the `VERCEL_ACCESS_TOKEN` and `VERCEL_PROJECT_ID` environment variables, or pass
|
||||
in the token and project ID as arguments to the `syncVercelEnvVars` build extension. If you're
|
||||
working with a team project, you'll also need to set `VERCEL_TEAM_ID`, which can be found in your
|
||||
team settings.
|
||||
|
||||
<Note>
|
||||
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
|
||||
environment variable values will be read from `process.env` instead of fetching them from the
|
||||
Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The
|
||||
API is still used to determine which environment variables are configured for your project, but
|
||||
the actual values come from the local environment. Reading values from `process.env` allows the
|
||||
extension to use values that Vercel integrations (such as the Neon integration) set per preview
|
||||
deployment in the "Provisioning Integrations" phase that happens just before the Vercel build
|
||||
starts.
|
||||
</Note>
|
||||
You can find / generate the `VERCEL_ACCESS_TOKEN` in your Vercel
|
||||
[dashboard](https://vercel.com/account/settings/tokens). Make sure the scope of the token covers
|
||||
the project with the environment variables you want to sync.
|
||||
</Accordion>
|
||||
|
||||
<Note>
|
||||
If you have the Neon database Vercel integration installed and are running builds outside of the
|
||||
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
|
||||
database environment variables. This ensures that the correct database connection strings are used for your
|
||||
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
|
||||
branch-specific database credentials when run locally.
|
||||
</Note>
|
||||
<Accordion title="Running in Vercel build environment">
|
||||
When running the build from a Vercel build environment (e.g., during a Vercel deployment), the
|
||||
environment variable values will be read from `process.env` instead of fetching them from the
|
||||
Vercel API. This is determined by checking if the `VERCEL` environment variable is present.
|
||||
|
||||
The API is still used to determine which environment variables are configured for your project, but
|
||||
the actual values come from the local environment. Reading values from `process.env` allows the
|
||||
extension to use values that Vercel integrations (such as the Neon integration) set per preview
|
||||
deployment in the "Provisioning Integrations" phase that happens just before the Vercel build
|
||||
starts.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Using with Neon database Vercel integration">
|
||||
If you have the Neon database Vercel integration installed and are running builds outside of the
|
||||
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
|
||||
database environment variables.
|
||||
|
||||
This ensures that the correct database connection strings are used for your
|
||||
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
|
||||
branch-specific database credentials when run locally.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
@@ -138,25 +145,29 @@ export default defineConfig({
|
||||
|
||||
The `syncNeonEnvVars` build extension syncs environment variables from your Neon database project to Trigger.dev. It automatically detects branches and builds the appropriate database connection strings for your environment.
|
||||
|
||||
<Note>
|
||||
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
|
||||
as arguments to the `syncNeonEnvVars` build extension. You can generate a `NEON_ACCESS_TOKEN` in
|
||||
your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
|
||||
</Note>
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting up authentication">
|
||||
You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them
|
||||
as arguments to the `syncNeonEnvVars` build extension.
|
||||
|
||||
<Note>
|
||||
When running the build from a Vercel environment (determined by checking if the `VERCEL`
|
||||
environment variable is present), this extension is skipped entirely. This is because Neon's
|
||||
Vercel integration already handles environment variable synchronization in Vercel environments.
|
||||
</Note>
|
||||
You can generate a `NEON_ACCESS_TOKEN` in your Neon [dashboard](https://console.neon.tech/app/settings/api-keys).
|
||||
</Accordion>
|
||||
|
||||
<Note>
|
||||
If you have the Neon database Vercel integration installed and are running builds outside of the
|
||||
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
|
||||
database environment variables. This ensures that the correct database connection strings are used for your
|
||||
selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect
|
||||
branch-specific database credentials when run locally.
|
||||
</Note>
|
||||
<Accordion title="Running in Vercel environment">
|
||||
When running the build from a Vercel environment (determined by checking if the `VERCEL`
|
||||
environment variable is present), this extension is skipped entirely.
|
||||
|
||||
This is because Neon's Vercel integration already handles environment variable synchronization in Vercel environments.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Using with Neon database Vercel integration">
|
||||
If you have the Neon database Vercel integration installed and are running builds outside of the
|
||||
Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your
|
||||
database environment variables.
|
||||
|
||||
This ensures that the correct database connection strings are used for your selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect branch-specific database credentials when run locally.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
<Note>
|
||||
This extension is skipped for `prod` environments. It is designed to sync branch-specific
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user