Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b8797fbb | |||
| 107f2e7bd5 | |||
| b8477ea2b0 | |||
| 864498ea85 | |||
| 2e21bc1563 | |||
| 1477a2e309 | |||
| 2081cb15b8 | |||
| ff551c5cbd | |||
| 794b25a988 | |||
| 5fdb43ecd1 | |||
| 80997bcd6d | |||
| 194f336b84 | |||
| 2e02743170 | |||
| 1642fd7baf | |||
| c6449126b1 | |||
| 1ccf38e440 | |||
| 29b773cd36 | |||
| 73fb7cfeb3 | |||
| 5431638926 | |||
| 933a14b44c | |||
| 4ff07f8a94 | |||
| 0afdb4cc7c | |||
| af9957c085 | |||
| 5c4275619b | |||
| 892fd9f212 | |||
| cbe9317a86 | |||
| 6baf9e5294 | |||
| a823421324 | |||
| 0e919f56f2 | |||
| f90960fc13 | |||
| 2306217697 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue with scoped packages in additionalPackages option
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Better handle uncaught exceptions
|
||||
@@ -44,6 +44,7 @@
|
||||
"@trigger.dev/yalt": "2.3.18"
|
||||
},
|
||||
"changesets": [
|
||||
"afraid-sheep-joke",
|
||||
"angry-eagles-trade",
|
||||
"beige-pens-dance",
|
||||
"big-tomatoes-deliver",
|
||||
@@ -68,6 +69,7 @@
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
"late-steaks-behave",
|
||||
"lazy-files-lay",
|
||||
"lemon-jobs-repair",
|
||||
"light-bulldogs-press",
|
||||
"light-dragons-complain",
|
||||
@@ -80,6 +82,7 @@
|
||||
"mighty-flowers-train",
|
||||
"nasty-jars-pump",
|
||||
"new-rivers-tell",
|
||||
"nice-bulldogs-turn",
|
||||
"ninety-pets-travel",
|
||||
"odd-poets-own",
|
||||
"pink-pumas-rhyme",
|
||||
|
||||
+4
-4
@@ -12,10 +12,10 @@ APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis is used for concurrency control
|
||||
# REDIS_HOST="localhost"
|
||||
# REDIS_PORT="6379"
|
||||
# REDIS_TLS_DISABLED="true"
|
||||
# Redis is used for the v3 queuing and v2 concurrency control
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_TLS_DISABLED="true"
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
|
||||
import { randomUUID } from "crypto";
|
||||
import { TaskMonitor } from "./taskMonitor";
|
||||
import { PodCleaner } from "./podCleaner";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "local";
|
||||
@@ -543,3 +544,11 @@ const taskMonitor = new TaskMonitor({
|
||||
});
|
||||
|
||||
taskMonitor.start();
|
||||
|
||||
const podCleaner = new PodCleaner({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: "default",
|
||||
intervalInSeconds: 300,
|
||||
});
|
||||
|
||||
podCleaner.start();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps";
|
||||
|
||||
type PodCleanerOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
namespace?: string;
|
||||
intervalInSeconds?: number;
|
||||
};
|
||||
|
||||
export class PodCleaner {
|
||||
private enabled = false;
|
||||
private namespace = "default";
|
||||
private intervalInSeconds = 300;
|
||||
|
||||
private logger = new SimpleLogger("[PodCleaner]");
|
||||
private k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
};
|
||||
|
||||
constructor(private opts: PodCleanerOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
if (opts.intervalInSeconds) {
|
||||
this.intervalInSeconds = opts.intervalInSeconds;
|
||||
}
|
||||
|
||||
this.k8sClient = this.#createK8sClient();
|
||||
}
|
||||
|
||||
#createK8sClient() {
|
||||
const kubeConfig = new k8s.KubeConfig();
|
||||
|
||||
if (this.opts.runtimeEnv === "local") {
|
||||
kubeConfig.loadFromDefault();
|
||||
} else if (this.opts.runtimeEnv === "kubernetes") {
|
||||
kubeConfig.loadFromCluster();
|
||||
} else {
|
||||
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
|
||||
}
|
||||
|
||||
return {
|
||||
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
|
||||
kubeConfig: kubeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
|
||||
if (typeof candidate !== "object" || candidate === null) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#logK8sError(err: unknown, debugOnly = false) {
|
||||
if (debugOnly) {
|
||||
this.logger.debug("K8s API Error", err);
|
||||
} else {
|
||||
this.logger.error("K8s API Error", err);
|
||||
}
|
||||
}
|
||||
|
||||
#handleK8sError(err: unknown) {
|
||||
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
|
||||
this.#logK8sError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError(err, true);
|
||||
|
||||
if (typeof err.body.message === "string") {
|
||||
this.#logK8sError({ message: err.body.message });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError({ body: err.body });
|
||||
}
|
||||
|
||||
async #deletePods(opts: {
|
||||
namespace: string;
|
||||
dryRun?: boolean;
|
||||
fieldSelector?: string;
|
||||
labelSelector?: string;
|
||||
}) {
|
||||
return await this.k8sClient.core
|
||||
.deleteCollectionNamespacedPod(
|
||||
opts.namespace,
|
||||
undefined, // pretty
|
||||
undefined, // continue
|
||||
opts.dryRun ? "All" : undefined,
|
||||
opts.fieldSelector,
|
||||
undefined, // gracePeriodSeconds
|
||||
opts.labelSelector
|
||||
)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
}
|
||||
|
||||
async #deleteCompletedRuns() {
|
||||
this.logger.log("Deleting completed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Succeeded",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteFailedRuns() {
|
||||
this.logger.log("Deleting failed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Failed",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteUnrecoverableRuns() {
|
||||
await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=?",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.enabled = true;
|
||||
this.logger.log("Starting");
|
||||
|
||||
const completedInterval = setInterval(async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(completedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteCompletedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
}, this.intervalInSeconds * 1000);
|
||||
|
||||
const failedInterval = setInterval(
|
||||
async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(failedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteFailedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
},
|
||||
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
|
||||
2 * this.intervalInSeconds * 1000
|
||||
);
|
||||
|
||||
// this.#launchTests();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = false;
|
||||
this.logger.log("Shutting down..");
|
||||
}
|
||||
|
||||
async #launchTests() {
|
||||
const createPod = async (
|
||||
container: k8s.V1Container,
|
||||
name: string,
|
||||
labels?: Record<string, string>
|
||||
) => {
|
||||
this.logger.log("Creating pod:", name);
|
||||
|
||||
const pod = {
|
||||
metadata: {
|
||||
name,
|
||||
labels,
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
terminationGracePeriodSeconds: 1,
|
||||
containers: [container],
|
||||
},
|
||||
} satisfies k8s.V1Pod;
|
||||
|
||||
await this.k8sClient.core
|
||||
.createNamespacedPod(this.namespace, pod)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
};
|
||||
|
||||
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "sleep infinity"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "true"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "false"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
await createIdlePod("test-idle-1", { app: "task-run" });
|
||||
await createFailedPod("test-failed-1", { app: "task-run" });
|
||||
await createCompletedPod("test-completed-1", { app: "task-run" });
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
|
||||
|
||||
export class TaskMonitor {
|
||||
#enabled = false;
|
||||
|
||||
#logger = new SimpleLogger("[TaskMonitor]");
|
||||
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
|
||||
#processedPods = new Map<string, number>();
|
||||
#queue = new PQueue({ concurrency: 10 });
|
||||
|
||||
#k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
@@ -44,6 +46,10 @@ export class TaskMonitor {
|
||||
private labelSelector = "app in (task-index, task-run)";
|
||||
|
||||
constructor(private opts: TaskMonitorOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
this.#k8sClient = this.#createK8sClient();
|
||||
|
||||
this.#taskInformer = this.#createTaskInformer();
|
||||
|
||||
@@ -290,6 +290,7 @@ export function TierPro({
|
||||
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
|
||||
fullWidth
|
||||
value={concurrentBracketCode}
|
||||
variant="primary"
|
||||
onChange={(v) => setConcurrentBracketCode(v)}
|
||||
/>
|
||||
<div className="py-6">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
const variants = {
|
||||
@@ -33,6 +35,74 @@ export function EnvironmentLabel({
|
||||
);
|
||||
}
|
||||
|
||||
type EnvironmentWithUsername = Environment & { userName?: string };
|
||||
|
||||
export function EnvironmentLabels({
|
||||
environments,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environments: EnvironmentWithUsername[];
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
const devEnvironments = sortEnvironments(
|
||||
environments.filter((env) => env.type === "DEVELOPMENT")
|
||||
);
|
||||
const firstDevEnvironment = devEnvironments[0];
|
||||
const otherDevEnvironments = devEnvironments.slice(1);
|
||||
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-2", className)}>
|
||||
{firstDevEnvironment && (
|
||||
<EnvironmentLabel
|
||||
environment={firstDevEnvironment}
|
||||
userName={firstDevEnvironment.userName}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{otherDevEnvironments.length > 0 ? (
|
||||
<SimpleTooltip
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName({ type: "DEVELOPMENT" }),
|
||||
environmentTextClassName({ type: "DEVELOPMENT" }),
|
||||
variants[size]
|
||||
)}
|
||||
>
|
||||
+{otherDevEnvironments.length}
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<div className="flex gap-1 py-1">
|
||||
{otherDevEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{otherEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CursorArrowRaysIcon,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
v3ApiKeysPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3RunsPath,
|
||||
@@ -601,6 +603,13 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
|
||||
@@ -48,11 +48,11 @@ const theme = {
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
|
||||
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
|
||||
button:
|
||||
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
|
||||
@@ -49,12 +49,7 @@ export function DetailCell({
|
||||
const variation = variations[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
|
||||
<IconInBox
|
||||
icon={leadingIcon}
|
||||
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
|
||||
@@ -62,20 +57,14 @@ export function DetailCell({
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Paragraph
|
||||
variant={variation.label.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left transition group-hover:text-text-bright",
|
||||
variation.label.className
|
||||
)}
|
||||
className={cn("flex-1 text-left", variation.label.className)}
|
||||
>
|
||||
{label}
|
||||
</Paragraph>
|
||||
{description && (
|
||||
<Paragraph
|
||||
variant={variation.description.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
|
||||
variation.description.className
|
||||
)}
|
||||
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
|
||||
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
base: "bg-charcoal-700",
|
||||
active: "text-text-bright hover:bg-charcoal-750/50",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-charcoal-700/50",
|
||||
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
variant?: Variants;
|
||||
fullWidth?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
@@ -21,11 +35,18 @@ export default function SegmentedControl({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
variant = "secondary",
|
||||
fullWidth,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 rounded text-text-bright",
|
||||
variants[variant].base,
|
||||
fullWidth ? "w-full" : "w-fit"
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={value}
|
||||
defaultValue={defaultValue ?? options[0].value}
|
||||
@@ -46,11 +67,11 @@ export default function SegmentedControl({
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
|
||||
active
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
checked
|
||||
? "text-text-bright"
|
||||
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
|
||||
? variants[variant].active
|
||||
: "text-text-dimmed transition hover:text-text-bright"
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -60,12 +81,12 @@ export default function SegmentedControl({
|
||||
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
|
||||
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
|
||||
</div>
|
||||
{checked && (
|
||||
{checked && variant === "primary" && (
|
||||
<motion.div
|
||||
layoutId={`segmented-control-${name}`}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
|
||||
></motion.div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -174,6 +174,10 @@ Worker.init().catch((error) => {
|
||||
|
||||
function logError(error: unknown, request?: Request) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error && error.message.startsWith("There are locked jobs present")) {
|
||||
console.log("⚠️ graphile-worker migration issue detected!");
|
||||
}
|
||||
}
|
||||
|
||||
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
|
||||
|
||||
@@ -153,6 +153,9 @@ const EnvironmentSchema = z.object({
|
||||
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
|
||||
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
|
||||
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
|
||||
|
||||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import {
|
||||
IntegrationService,
|
||||
Organization,
|
||||
OrganizationIntegration,
|
||||
SecretReference,
|
||||
} from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
const SlackSecretSchema = z.object({
|
||||
botAccessToken: z.string(),
|
||||
userAccessToken: z.string().optional(),
|
||||
expiresIn: z.number().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
botScopes: z.array(z.string()).optional(),
|
||||
userScopes: z.array(z.string()).optional(),
|
||||
raw: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
type SlackSecret = z.infer<typeof SlackSecretSchema>;
|
||||
|
||||
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
|
||||
|
||||
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
|
||||
AuthenticatableIntegration,
|
||||
"service"
|
||||
> & {
|
||||
service: TService;
|
||||
};
|
||||
|
||||
type AuthenticatedClientOptions<TService extends IntegrationService> = TService extends "SLACK"
|
||||
? {
|
||||
forceBotToken?: boolean;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type AuthenticatedClientForIntegration<TService extends IntegrationService> =
|
||||
TService extends "SLACK" ? InstanceType<typeof WebClient> : never;
|
||||
|
||||
export type AuthenticatableIntegration = OrganizationIntegration & {
|
||||
tokenReference: SecretReference;
|
||||
};
|
||||
|
||||
export class OrgIntegrationRepository {
|
||||
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
|
||||
integration: OrganizationIntegrationForService<TService>,
|
||||
options?: AuthenticatedClientOptions<TService>
|
||||
): Promise<AuthenticatedClientForIntegration<TService>> {
|
||||
const secretStore = getSecretStore(integration.tokenReference.provider);
|
||||
|
||||
switch (integration.service) {
|
||||
case "SLACK": {
|
||||
const secret = await secretStore.getSecret(
|
||||
SlackSecretSchema,
|
||||
integration.tokenReference.key
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
// TODO refresh access token here
|
||||
return new WebClient(
|
||||
options?.forceBotToken
|
||||
? secret.botAccessToken
|
||||
: secret.userAccessToken ?? secret.botAccessToken
|
||||
) as AuthenticatedClientForIntegration<TService>;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported service ${integration.service}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static isSlackSupported =
|
||||
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
|
||||
|
||||
static slackAuthorizationUrl(
|
||||
state: string,
|
||||
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
|
||||
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
|
||||
) {
|
||||
return `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
env.ORG_SLACK_INTEGRATION_CLIENT_ID
|
||||
}&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${
|
||||
env.APP_ORIGIN
|
||||
}/integrations/slack/callback`;
|
||||
}
|
||||
|
||||
static async redirectToAuthService(
|
||||
service: IntegrationService,
|
||||
state: string,
|
||||
request: Request,
|
||||
redirectTo: string
|
||||
) {
|
||||
const session = await getUserSession(request);
|
||||
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
|
||||
|
||||
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
|
||||
|
||||
if (!authUrl) {
|
||||
throw new Response("Unsupported service", { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Redirecting to auth service", {
|
||||
service,
|
||||
authUrl,
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: authUrl,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async redirectAfterAuth(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
|
||||
logger.debug("Redirecting back after auth", {
|
||||
sessionData: session.data,
|
||||
});
|
||||
|
||||
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
if (!redirectTo) {
|
||||
throw new Response("Invalid redirect", { status: 400 });
|
||||
}
|
||||
|
||||
session.unset(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: redirectTo,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
|
||||
switch (serviceName) {
|
||||
case "slack": {
|
||||
if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) {
|
||||
throw new Error("Slack integration not configured");
|
||||
}
|
||||
|
||||
const client = new WebClient();
|
||||
|
||||
const result = await client.oauth.v2.access({
|
||||
client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID,
|
||||
client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug("Received slack access token", {
|
||||
result,
|
||||
});
|
||||
|
||||
if (!result.access_token) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
return await $transaction(prisma, async (tx) => {
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
const integrationFriendlyId = generateFriendlyId("org_integration");
|
||||
|
||||
const secretValue: SlackSecret = {
|
||||
botAccessToken: result.access_token!,
|
||||
userAccessToken: result.authed_user ? result.authed_user.access_token : undefined,
|
||||
expiresIn: result.expires_in,
|
||||
refreshToken: result.refresh_token,
|
||||
botScopes: result.scope ? result.scope.split(",") : [],
|
||||
userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [],
|
||||
raw: result,
|
||||
};
|
||||
|
||||
logger.debug("Setting secret", {
|
||||
secretValue,
|
||||
});
|
||||
|
||||
await secretStore.setSecret(integrationFriendlyId, secretValue);
|
||||
|
||||
const reference = await tx.secretReference.create({
|
||||
data: {
|
||||
provider: "DATABASE",
|
||||
key: integrationFriendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.organizationIntegration.create({
|
||||
data: {
|
||||
friendlyId: integrationFriendlyId,
|
||||
organizationId: org.id,
|
||||
service: "SLACK",
|
||||
tokenReferenceId: reference.id,
|
||||
integrationData: {
|
||||
team: result.team,
|
||||
user: result.authed_user
|
||||
? {
|
||||
id: result.authed_user.id,
|
||||
}
|
||||
: undefined,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Service ${serviceName} not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,3 +110,15 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findProjectByRef(externalRef: string, userId: string) {
|
||||
// Find the project scoped to the organization, making sure the user belongs to that org
|
||||
return await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export const ProjectAlertWebhookProperties = z.object({
|
||||
secret: EncryptedSecretValueSchema,
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
|
||||
|
||||
export const ProjectAlertEmailProperties = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertEmailProperties = z.infer<typeof ProjectAlertEmailProperties>;
|
||||
|
||||
export const DeleteProjectAlertChannel = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectAlertSlackProperties = z.object({
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
integrationId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackProperties = z.infer<typeof ProjectAlertSlackProperties>;
|
||||
|
||||
export const ProjectAlertSlackStorage = z.object({
|
||||
message_ts: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
|
||||
@@ -1,20 +1,21 @@
|
||||
import type {
|
||||
CronItem,
|
||||
CronItemOptions,
|
||||
Job as GraphileJob,
|
||||
DbJob as GraphileJob,
|
||||
Runner as GraphileRunner,
|
||||
JobHelpers,
|
||||
RunnerOptions,
|
||||
Task,
|
||||
TaskList,
|
||||
TaskSpec,
|
||||
WorkerUtils,
|
||||
} from "graphile-worker";
|
||||
import { run as graphileRun, parseCronItems } from "graphile-worker";
|
||||
import { run as graphileRun, makeWorkerUtils, parseCronItems } from "graphile-worker";
|
||||
import { SpanKind, trace } from "@opentelemetry/api";
|
||||
|
||||
import omit from "lodash.omit";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { $replica, PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { PgListenService } from "~/services/db/pgListen.server";
|
||||
import { workerLogger as logger } from "~/services/logger.server";
|
||||
import { flattenAttributes } from "@trigger.dev/core/v3";
|
||||
@@ -34,8 +35,8 @@ const RawCronPayloadSchema = z.object({
|
||||
|
||||
const GraphileJobSchema = z.object({
|
||||
id: z.coerce.string(),
|
||||
queue_name: z.string().nullable(),
|
||||
task_identifier: z.string(),
|
||||
job_queue_id: z.number().nullable(),
|
||||
task_id: z.number(),
|
||||
payload: z.unknown(),
|
||||
priority: z.number(),
|
||||
run_at: z.coerce.date(),
|
||||
@@ -72,7 +73,7 @@ type RecurringTaskPayload = {
|
||||
|
||||
export type ZodRecurringTasks = {
|
||||
[key: string]: {
|
||||
pattern: string;
|
||||
match: string;
|
||||
options?: CronItemOptions;
|
||||
handler: (payload: RecurringTaskPayload, job: GraphileJob) => Promise<void>;
|
||||
};
|
||||
@@ -129,6 +130,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#rateLimiter?: ZodWorkerRateLimiter;
|
||||
#shutdownTimeoutInMs?: number;
|
||||
#shuttingDown = false;
|
||||
#workerUtils?: WorkerUtils;
|
||||
|
||||
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
|
||||
this.#name = options.name;
|
||||
@@ -158,6 +160,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
const parsedCronItems = parseCronItems(this.#createCronItemsFromRecurringTasks());
|
||||
|
||||
this.#workerUtils = await makeWorkerUtils(this.#runnerOptions);
|
||||
|
||||
this.#runner = await graphileRun({
|
||||
...this.#runnerOptions,
|
||||
noHandleSignals: true,
|
||||
@@ -188,7 +192,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#logDebug("Detected incoming migration", { latestMigration });
|
||||
|
||||
if (latestMigration > 10) {
|
||||
// already migrated past v0.14 - nothing to do
|
||||
this.#logDebug("Already migrated past v0.14 - nothing to do", { latestMigration });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,6 +267,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
public async stop() {
|
||||
await this.#runner?.stop();
|
||||
await this.#workerUtils?.release();
|
||||
}
|
||||
|
||||
public async enqueue<K extends keyof TMessageCatalog>(
|
||||
@@ -442,12 +447,29 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return taskList;
|
||||
}
|
||||
|
||||
async #getQueueName(queueId: number | null) {
|
||||
if (queueId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = z.array(z.object({ queue_name: z.string() }));
|
||||
|
||||
const rawQueueNameResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT queue_name FROM ${this.graphileWorkerSchema}._private_job_queues WHERE id = $1`,
|
||||
queueId
|
||||
);
|
||||
|
||||
const queueNameResults = schema.parse(rawQueueNameResults);
|
||||
|
||||
return queueNameResults[0]?.queue_name;
|
||||
}
|
||||
|
||||
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
|
||||
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
|
||||
|
||||
await this.enqueue(helpers.job.task_identifier, payload, {
|
||||
runAt: helpers.job.run_at,
|
||||
queueName: helpers.job.queue_name ?? undefined,
|
||||
runAt: new Date(Date.now() + 1000 * 10),
|
||||
queueName: await this.#getQueueName(helpers.job.job_queue_id),
|
||||
priority: helpers.job.priority,
|
||||
jobKey: helpers.job.key ?? undefined,
|
||||
flags: Object.keys(helpers.job.flags ?? []),
|
||||
@@ -460,7 +482,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
if (this.#cleanup) {
|
||||
cronItems.push({
|
||||
pattern: this.#cleanup.frequencyExpression,
|
||||
match: this.#cleanup.frequencyExpression,
|
||||
identifier: CLEANUP_TASK_NAME,
|
||||
task: CLEANUP_TASK_NAME,
|
||||
options: this.#cleanup.taskOptions,
|
||||
@@ -469,7 +491,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
if (this.#reporter) {
|
||||
cronItems.push({
|
||||
pattern: "50 * * * *", // Every hour at 50 minutes past the hour
|
||||
match: "50 * * * *", // Every hour at 50 minutes past the hour
|
||||
identifier: REPORTER_TASK_NAME,
|
||||
task: REPORTER_TASK_NAME,
|
||||
});
|
||||
@@ -481,7 +503,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
for (const [key, task] of Object.entries(this.#recurringTasks)) {
|
||||
const cronItem: CronItem = {
|
||||
pattern: task.pattern,
|
||||
match: task.match,
|
||||
identifier: key,
|
||||
task: key,
|
||||
options: task.options,
|
||||
@@ -529,7 +551,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
attributes: {
|
||||
"job.task_identifier": job.task_identifier,
|
||||
"job.id": job.id,
|
||||
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
|
||||
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
|
||||
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
|
||||
"job.priority": job.priority,
|
||||
"job.run_at": job.run_at.toISOString(),
|
||||
@@ -599,7 +621,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
attributes: {
|
||||
"job.task_identifier": job.task_identifier,
|
||||
"job.id": job.id,
|
||||
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
|
||||
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
|
||||
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
|
||||
"job.priority": job.priority,
|
||||
"job.run_at": job.run_at.toISOString(),
|
||||
@@ -638,6 +660,10 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#workerUtils) {
|
||||
throw new Error("WorkerUtils need to be initialized before running job cleanup.");
|
||||
}
|
||||
|
||||
const job = helpers.job;
|
||||
|
||||
logger.debug("Received cleanup task", {
|
||||
@@ -663,23 +689,38 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
payload,
|
||||
});
|
||||
|
||||
const rawResults = await this.#prisma.$queryRawUnsafe(
|
||||
`WITH rows AS (SELECT id FROM ${this.graphileWorkerSchema}.jobs WHERE run_at < $1 AND locked_at IS NULL AND max_attempts = attempts LIMIT $2 FOR UPDATE) DELETE FROM ${this.graphileWorkerSchema}.jobs WHERE id IN (SELECT id FROM rows) RETURNING id`,
|
||||
const rawResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT id
|
||||
FROM ${this.graphileWorkerSchema}.jobs
|
||||
WHERE run_at < $1
|
||||
AND locked_at IS NULL
|
||||
AND max_attempts = attempts
|
||||
LIMIT $2`,
|
||||
expirationDate,
|
||||
this.#cleanup.maxCount
|
||||
);
|
||||
|
||||
const results = Array.isArray(rawResults) ? rawResults : [];
|
||||
const results = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.coerce.string(),
|
||||
})
|
||||
)
|
||||
.parse(rawResults);
|
||||
|
||||
const completedJobs = await this.#workerUtils.completeJobs(results.map((job) => job.id));
|
||||
|
||||
logger.debug("Cleaned up old jobs", {
|
||||
count: results.length,
|
||||
found: results.length,
|
||||
deleted: completedJobs.length,
|
||||
expirationDate,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (this.#reporter) {
|
||||
await this.#reporter("cleanup_stats", {
|
||||
count: results.length,
|
||||
found: results.length,
|
||||
deleted: completedJobs.length,
|
||||
expirationDate,
|
||||
ts: payload._cron.ts,
|
||||
});
|
||||
@@ -711,7 +752,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const schema = z.array(z.object({ count: z.coerce.number() }));
|
||||
|
||||
// Count the number of jobs that have been added since the startAt date and before the payload._cron.ts date
|
||||
const rawAddedResults = await this.#prisma.$queryRawUnsafe(
|
||||
const rawAddedResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs WHERE created_at > $1 AND created_at < $2`,
|
||||
startAt,
|
||||
payload._cron.ts
|
||||
@@ -720,7 +761,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const addedCountResults = schema.parse(rawAddedResults)[0];
|
||||
|
||||
// Count the total number of jobs in the jobs table
|
||||
const rawTotalResults = await this.#prisma.$queryRawUnsafe(
|
||||
const rawTotalResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs`
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
IndexEndpointStats,
|
||||
parseEndpointIndexStats,
|
||||
} from "@trigger.dev/core";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type Client = {
|
||||
slug: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export class ProjectPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertSlackProperties,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
|
||||
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
|
||||
export type AlertChannelListPresenterRecord =
|
||||
AlertChannelListPresenterData["alertChannels"][number];
|
||||
export type AlertChannelListPresenterAlertProperties = NonNullable<
|
||||
AlertChannelListPresenterRecord["properties"]
|
||||
>;
|
||||
|
||||
export class AlertChannelListPresenter extends BasePresenter {
|
||||
public async call(projectId: string) {
|
||||
logger.debug("AlertChannelListPresenter", { projectId });
|
||||
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
alertChannels: await Promise.all(
|
||||
alertChannels.map(async (alertChannel) => ({
|
||||
...alertChannel,
|
||||
properties: await this.#presentProperties(alertChannel),
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async #presentProperties(alertChannel: ProjectAlertChannel) {
|
||||
if (!alertChannel.properties) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alertChannel.type) {
|
||||
case "WEBHOOK":
|
||||
const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties);
|
||||
|
||||
const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret);
|
||||
|
||||
return {
|
||||
type: "WEBHOOK" as const,
|
||||
url: parsedProperties.url,
|
||||
secret,
|
||||
};
|
||||
case "EMAIL":
|
||||
return {
|
||||
type: "EMAIL" as const,
|
||||
...ProjectAlertEmailProperties.parse(alertChannel.properties),
|
||||
};
|
||||
case "SLACK": {
|
||||
return {
|
||||
type: "SLACK" as const,
|
||||
...ProjectAlertSlackProperties.parse(alertChannel.properties),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported alert channel type: ${alertChannel.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ProjectAlertChannel,
|
||||
ProjectAlertChannelType,
|
||||
ProjectAlertType,
|
||||
} from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "deployment_success"]);
|
||||
|
||||
export type ApiAlertType = z.infer<typeof ApiAlertType>;
|
||||
|
||||
export const ApiAlertChannel = z.enum(["email", "webhook"]);
|
||||
|
||||
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
|
||||
|
||||
export const ApiAlertChannelData = z.object({
|
||||
email: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiAlertChannelData = z.infer<typeof ApiAlertChannelData>;
|
||||
|
||||
export const ApiCreateAlertChannel = z.object({
|
||||
alertTypes: ApiAlertType.array(),
|
||||
name: z.string(),
|
||||
channel: ApiAlertChannel,
|
||||
channelData: ApiAlertChannelData,
|
||||
deduplicationKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
|
||||
|
||||
export const ApiAlertChannelObject = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
alertTypes: ApiAlertType.array(),
|
||||
channel: ApiAlertChannel,
|
||||
channelData: ApiAlertChannelData,
|
||||
deduplicationKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiAlertChannelObject = z.infer<typeof ApiAlertChannelObject>;
|
||||
|
||||
export class ApiAlertChannelPresenter {
|
||||
public static async alertChannelToApi(
|
||||
alertChannel: ProjectAlertChannel
|
||||
): Promise<ApiAlertChannelObject> {
|
||||
return {
|
||||
id: alertChannel.friendlyId,
|
||||
name: alertChannel.name,
|
||||
alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)),
|
||||
channel: this.alertChannelTypeToApi(alertChannel.type),
|
||||
channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties),
|
||||
deduplicationKey: alertChannel.userProvidedDeduplicationKey
|
||||
? alertChannel.deduplicationKey
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType {
|
||||
switch (alertType) {
|
||||
case "TASK_RUN_ATTEMPT":
|
||||
return "attempt_failure";
|
||||
case "DEPLOYMENT_FAILURE":
|
||||
return "deployment_failure";
|
||||
case "DEPLOYMENT_SUCCESS":
|
||||
return "deployment_success";
|
||||
default:
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
|
||||
public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType {
|
||||
switch (alertType) {
|
||||
case "attempt_failure":
|
||||
return "TASK_RUN_ATTEMPT";
|
||||
case "deployment_failure":
|
||||
return "DEPLOYMENT_FAILURE";
|
||||
case "deployment_success":
|
||||
return "DEPLOYMENT_SUCCESS";
|
||||
default:
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
|
||||
public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel {
|
||||
switch (type) {
|
||||
case "EMAIL":
|
||||
return "email";
|
||||
case "WEBHOOK":
|
||||
return "webhook";
|
||||
case "SLACK":
|
||||
throw new Error("Slack channels are not supported");
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function channelDataFromProperties(
|
||||
type: ProjectAlertChannelType,
|
||||
properties: ProjectAlertChannel["properties"]
|
||||
): Promise<ApiAlertChannelData> {
|
||||
if (!properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "EMAIL":
|
||||
return ProjectAlertEmailProperties.parse(properties);
|
||||
case "WEBHOOK":
|
||||
const { url, secret } = ProjectAlertWebhookProperties.parse(properties);
|
||||
|
||||
return {
|
||||
url,
|
||||
secret: await decryptSecret(env.ENCRYPTION_KEY, secret),
|
||||
};
|
||||
case "SLACK":
|
||||
throw new Error("Slack channels are not supported");
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export class ApiKeysPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -144,18 +144,18 @@ export class DeploymentPresenter {
|
||||
userName: getUsername(deployment.environment.orgMember?.user),
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
errorData: this.#prepareErrorData(deployment.errorData),
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
projectId: deployment.projectId,
|
||||
organizationId: project.organizationId,
|
||||
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
if (!errorData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AuthenticatableIntegration,
|
||||
OrgIntegrationRepository,
|
||||
} from "~/models/orgIntegration.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export class NewAlertChannelPresenter extends BasePresenter {
|
||||
public async call(projectId: string) {
|
||||
const project = await this._prisma.project.findUniqueOrThrow({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Find the latest Slack integration
|
||||
const slackIntegration = await this._prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
// If there is a slack integration, then we need to get a list of Slack Channels
|
||||
if (slackIntegration) {
|
||||
const channels = await getSlackChannelsForToken(slackIntegration);
|
||||
|
||||
return {
|
||||
slack: {
|
||||
status: "READY" as const,
|
||||
channels,
|
||||
integrationId: slackIntegration.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (OrgIntegrationRepository.isSlackSupported) {
|
||||
return {
|
||||
slack: {
|
||||
status: "NOT_CONFIGURED" as const,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
slack: {
|
||||
status: "NOT_AVAILABLE" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSlackChannelsForToken(integration: AuthenticatableIntegration) {
|
||||
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration);
|
||||
|
||||
const channels = await getAllSlackConversations(client);
|
||||
|
||||
logger.debug("Received a list of slack conversations", {
|
||||
channels,
|
||||
});
|
||||
|
||||
return (channels ?? [])
|
||||
.filter((channel) => !channel.is_archived)
|
||||
.filter((channel) => channel.is_channel)
|
||||
.filter((channel) => !channel.is_ext_shared)
|
||||
.filter((channel) => channel.unlinked === 0)
|
||||
.filter((channel) => channel.num_members)
|
||||
.sort((a, b) => a.name!.localeCompare(b.name!));
|
||||
}
|
||||
|
||||
type Channels = Awaited<ReturnType<WebClient["conversations"]["list"]>>["channels"];
|
||||
|
||||
async function getSlackConversationsPage(client: WebClient, nextCursor?: string) {
|
||||
return client.conversations.list({
|
||||
types: "public_channel,private_channel",
|
||||
exclude_archived: true,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAllSlackConversations(client: WebClient) {
|
||||
let nextCursor: string | undefined = undefined;
|
||||
let channels: Channels = [];
|
||||
|
||||
do {
|
||||
const response = await getSlackConversationsPage(client, nextCursor);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get channels: ${response.error}`);
|
||||
}
|
||||
|
||||
channels = channels.concat(response.channels ?? []);
|
||||
nextCursor = response.response_metadata?.next_cursor;
|
||||
} while (nextCursor);
|
||||
|
||||
return channels;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
|
||||
+2
-10
@@ -11,7 +11,7 @@ import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/component
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
@@ -261,15 +261,7 @@ export default function Page() {
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<div className="space-x-2">
|
||||
{task.environments.map((environment) => (
|
||||
<EnvironmentLabel
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
</TableRow>
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { getUserSession } from "~/services/sessionStorage.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewProjectAlertPathConnectToSlackPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Find an integration for Slack for this org
|
||||
const integration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (integration) {
|
||||
return redirectWithSuccessMessage(
|
||||
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
|
||||
request,
|
||||
"Successfully connected your Slack workspace"
|
||||
);
|
||||
} else {
|
||||
// Redirect to Slack
|
||||
return await OrgIntegrationRepository.redirectToAuthService(
|
||||
"SLACK",
|
||||
project.organizationId,
|
||||
request,
|
||||
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
|
||||
);
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/router";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
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 SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
CreateAlertChannelOptions,
|
||||
CreateAlertChannelService,
|
||||
} from "~/v3/services/alerts/createAlertChannel.server";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
alertTypes: z
|
||||
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
|
||||
.min(1)
|
||||
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
|
||||
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
|
||||
channelValue: z.string().nonempty(),
|
||||
integrationId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid email address",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid URL",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "SLACK"
|
||||
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
|
||||
: true,
|
||||
{
|
||||
message: "Must select a Slack channel",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
);
|
||||
|
||||
function formDataToCreateAlertChannelOptions(
|
||||
formData: z.infer<typeof FormSchema>
|
||||
): CreateAlertChannelOptions {
|
||||
switch (formData.type) {
|
||||
case "WEBHOOK": {
|
||||
return {
|
||||
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "EMAIL": {
|
||||
return {
|
||||
name: `Email to ${formData.channelValue}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "SLACK": {
|
||||
const [channelId, channelName] = formData.channelValue.split("/");
|
||||
|
||||
return {
|
||||
name: `Slack message to ${channelName}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "SLACK",
|
||||
channelId,
|
||||
channelName,
|
||||
integrationId: formData.integrationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new NewAlertChannelPresenter();
|
||||
|
||||
const results = await presenter.call(project.id);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const option = url.searchParams.get("option");
|
||||
|
||||
return typedjson({
|
||||
...results,
|
||||
option: option === "slack" ? ("SLACK" as const) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = parse(formData, { schema: FormSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
submission.error.key = "Project not found";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
const alertChannel = await service.call(
|
||||
project.externalRef,
|
||||
userId,
|
||||
formDataToCreateAlertChannelOptions(submission.value)
|
||||
);
|
||||
|
||||
if (!alertChannel) {
|
||||
submission.error.key = "Failed to create alert channel";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Created ${alertChannel.name} alert`
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
|
||||
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
|
||||
id: "create-alert",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
|
||||
form.ref.current?.reset();
|
||||
}, [navigation.state, lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3ProjectAlertsPath(organization, project));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>New alert</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset className="mt-2">
|
||||
<InputGroup fullWidth>
|
||||
<SegmentedControl
|
||||
{...conform.input(type)}
|
||||
options={[
|
||||
{ label: "Email", value: "EMAIL" },
|
||||
{ label: "Slack", value: "SLACK" },
|
||||
{ label: "Webhook", value: "WEBHOOK" },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setCurrentAlertChannel(value);
|
||||
}}
|
||||
fullWidth
|
||||
defaultValue={currentAlertChannel ?? undefined}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{currentAlertChannel === "EMAIL" ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
) : currentAlertChannel === "SLACK" ? (
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
<>
|
||||
<Select
|
||||
{...conform.select(channelValue)}
|
||||
placeholder="Select a Slack channel"
|
||||
heading="Filter channels…"
|
||||
defaultValue={undefined}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return <SlackChannelTitle {...channel} />;
|
||||
}}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
{matches?.map((channel) => (
|
||||
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
|
||||
<SlackChannelTitle {...channel} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
<Hint className="leading-relaxed">
|
||||
If selecting a private channel, you will need to invite the bot to the channel
|
||||
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
|
||||
</Hint>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
<input type="hidden" name="integrationId" value={slack.integrationId} />
|
||||
</>
|
||||
) : slack.status === "NOT_CONFIGURED" ? (
|
||||
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Slack integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</InputGroup>
|
||||
) : (
|
||||
<InputGroup fullWidth>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="https://foobar.com/webhooks"
|
||||
type="url"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Events</Label>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="TASK_RUN_ATTEMPT"
|
||||
value="TASK_RUN_ATTEMPT"
|
||||
variant="simple/small"
|
||||
label="Task run failure"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_FAILURE"
|
||||
value="DEPLOYMENT_FAILURE"
|
||||
variant="simple/small"
|
||||
label="Deployment failure"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_SUCCESS"
|
||||
value="DEPLOYMENT_SUCCESS"
|
||||
variant="simple/small"
|
||||
label="Deployment success"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormError>{form.error}</FormError>
|
||||
<div className="border-t border-grid-bright pt-3">
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create"
|
||||
>
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
BoltIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DetailCell } from "~/components/primitives/DetailCell";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
AlertChannelListPresenter,
|
||||
AlertChannelListPresenterRecord,
|
||||
} from "~/presenters/v3/AlertChannelListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
v3NewProjectAlertPath,
|
||||
v3ProjectAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new AlertChannelListPresenter();
|
||||
const data = await presenter.call(project.id);
|
||||
|
||||
return typedjson(data);
|
||||
};
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("delete"), id: z.string() }),
|
||||
z.object({ action: z.literal("disable"), id: z.string() }),
|
||||
z.object({ action: z.literal("enable"), id: z.string() }),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
submission.error.key = "Project not found";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "delete": {
|
||||
const alertChannel = await prisma.projectAlertChannel.delete({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Deleted ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "disable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Disabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "enable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: true },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Enabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { alertChannels } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Alerts" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/project-alerts")}
|
||||
variant="minimal/small"
|
||||
>
|
||||
Alerts docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("flex h-full flex-col gap-3")}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Alert Types</TableHeaderCell>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.length > 0 ? (
|
||||
alertChannels.map((alertChannel) => (
|
||||
<TableRow key={alertChannel.id}>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.name}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus enabled={alertChannel.enabled} />
|
||||
</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</TableCellMenu>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph>No alerts have been created</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "delete-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
<Button
|
||||
name="action"
|
||||
value="delete"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Deleting" : "Delete"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "disable-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="disable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltSlashIcon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Disabling" : "Disable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function EnableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "enable-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="enable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltIcon}
|
||||
leadingIconClassName="text-success"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Enabling" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
|
||||
switch (alertChannel.properties?.type) {
|
||||
case "EMAIL": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Email"}
|
||||
description={alertChannel.properties.email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={alertChannel.properties.url}
|
||||
description={
|
||||
<ClipboardField
|
||||
value={alertChannel.properties.secret}
|
||||
variant="secondary/small"
|
||||
icon={<LockClosedIcon className="size-4" />}
|
||||
iconButton
|
||||
secure={"•".repeat(alertChannel.properties.secret.length)}
|
||||
className="mt-1 w-80"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "SLACK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Slack"}
|
||||
description={`#${alertChannel.properties.channelName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alertTypeTitle(alertType: ProjectAlertType): string {
|
||||
switch (alertType) {
|
||||
case "TASK_RUN_ATTEMPT":
|
||||
return "Task attempt failure";
|
||||
case "DEPLOYMENT_FAILURE":
|
||||
return "Deployment failure";
|
||||
case "DEPLOYMENT_SUCCESS":
|
||||
return "Deployment success";
|
||||
default: {
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function AlertChannelTypeIcon({
|
||||
channelType,
|
||||
className,
|
||||
}: {
|
||||
channelType: ProjectAlertChannelType;
|
||||
className: string;
|
||||
}) {
|
||||
switch (channelType) {
|
||||
case "EMAIL":
|
||||
return <EnvelopeIcon className={className} />;
|
||||
case "SLACK":
|
||||
return <SlackIcon className={className} />;
|
||||
case "WEBHOOK":
|
||||
return <GlobeAltIcon className={className} />;
|
||||
default: {
|
||||
assertNever(channelType);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -46,13 +46,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new EnvironmentVariablesPresenter();
|
||||
const { environmentVariables, environments } = await presenter.call({
|
||||
const { environments } = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
environmentVariables,
|
||||
environments,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -150,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
|
||||
const { environments } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
+2
-11
@@ -8,7 +8,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
@@ -211,16 +211,7 @@ export default function Page() {
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Environments">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{schedule.environments.map((env) => (
|
||||
<EnvironmentLabel
|
||||
key={env.id}
|
||||
size="small"
|
||||
environment={env}
|
||||
userName={env.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels size="small" environments={schedule.environments} />
|
||||
</Property>
|
||||
<Property label="External ID">
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
|
||||
+2
-10
@@ -6,7 +6,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
@@ -281,15 +281,7 @@ function SchedulesTable({
|
||||
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<div className="flex gap-1">
|
||||
{schedule.environments.map((environment) => (
|
||||
<EnvironmentLabel
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels environments={schedule.environments} size="small" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiAlertChannelPresenter,
|
||||
ApiCreateAlertChannel,
|
||||
} from "~/presenters/v3/ApiAlertChannelPresenter.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid Params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectRef } = parsedParams.data;
|
||||
|
||||
const rawBody = await request.json();
|
||||
|
||||
const body = ApiCreateAlertChannel.safeParse(rawBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
|
||||
try {
|
||||
if (body.data.channel === "email") {
|
||||
if (!body.data.channelData.email) {
|
||||
return json({ error: "Email is required" }, { status: 422 });
|
||||
}
|
||||
|
||||
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
|
||||
name: body.data.name,
|
||||
alertTypes: body.data.alertTypes.map((type) =>
|
||||
ApiAlertChannelPresenter.alertTypeFromApi(type)
|
||||
),
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: body.data.channelData.email,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
}
|
||||
|
||||
if (body.data.channel === "webhook") {
|
||||
if (!body.data.channelData.url) {
|
||||
return json({ error: "webhook url is required" }, { status: 422 });
|
||||
}
|
||||
|
||||
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
|
||||
name: body.data.name,
|
||||
alertTypes: body.data.alertTypes.map((type) =>
|
||||
ApiAlertChannelPresenter.alertTypeFromApi(type)
|
||||
),
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: body.data.channelData.url,
|
||||
secret: body.data.channelData.secret,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
}
|
||||
|
||||
return json({ error: "Invalid channel type" }, { status: 422 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import z from "zod";
|
||||
import { redirectBackWithErrorMessage } from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { CreateOrgIntegrationService } from "~/v3/services/createOrgIntegration.server";
|
||||
|
||||
const URLSearchSchema = z
|
||||
.object({
|
||||
code: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "GET") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const url = requestUrl(request);
|
||||
|
||||
const parsedSearchParams = URLSearchSchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
|
||||
if (!parsedSearchParams.success) {
|
||||
// TODO: this needs to lookup the redirect url in the cookies
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
if (parsedSearchParams.data.error) {
|
||||
// TODO: this needs to lookup the redirect url in the cookies
|
||||
throw new Response(parsedSearchParams.data.error, { status: 400 });
|
||||
}
|
||||
|
||||
if (!parsedSearchParams.data.code || !parsedSearchParams.data.state) {
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
const service = new CreateOrgIntegrationService();
|
||||
|
||||
const integration = await service.call(
|
||||
userId,
|
||||
parsedSearchParams.data.state,
|
||||
parsedParams.data.serviceName,
|
||||
parsedSearchParams.data.code
|
||||
);
|
||||
|
||||
if (integration) {
|
||||
return await OrgIntegrationRepository.redirectAfterAuth(request);
|
||||
}
|
||||
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect to the service");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
|
||||
export function action({ request }: ActionFunctionArgs) {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -278,10 +278,8 @@ export default function Story() {
|
||||
<span className="text-charcoal-900">Continue with GitHub</span>
|
||||
</Button>
|
||||
<Button variant="secondary/large" fullWidth>
|
||||
<EnvelopeIcon
|
||||
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
|
||||
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
|
||||
<span className="text-secondary">Continue with Email</span>
|
||||
</Button>
|
||||
<Button variant="tertiary/large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
|
||||
@@ -308,10 +306,8 @@ export default function Story() {
|
||||
<span className="text-charcoal-900">Continue with GitHub</span>
|
||||
</Button>
|
||||
<Button variant="secondary/extra-large" fullWidth>
|
||||
<EnvelopeIcon
|
||||
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
|
||||
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
|
||||
<span className="text-secondary">Continue with Email</span>
|
||||
</Button>
|
||||
<Button variant="tertiary/extra-large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 h-5 w-5"} />
|
||||
|
||||
@@ -14,6 +14,13 @@ export default function Story() {
|
||||
>
|
||||
{isDisabled ? "Enable checkboxes" : "Disable checkboxes"}
|
||||
</Button>
|
||||
<Checkbox
|
||||
name="Simple checkbox"
|
||||
id="check1"
|
||||
variant="simple/small"
|
||||
label="This is a simple small checkbox"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<Checkbox
|
||||
name="Simple checkbox"
|
||||
id="check1"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
|
||||
const options = [
|
||||
@@ -8,8 +9,15 @@ const options = [
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<SegmentedControl name="name" options={options} />
|
||||
<MainCenteredContainer className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Paragraph>Primary</Paragraph>
|
||||
<SegmentedControl name="name" options={options} variant="primary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Paragraph>Secondary</Paragraph>
|
||||
<SegmentedControl name="name" options={options} variant="secondary" />
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { runMigrations } from "graphile-worker";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { PgNotifyService } from "./pgNotify.server";
|
||||
import { z } from "zod";
|
||||
|
||||
export class GraphileMigrationHelperService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call() {
|
||||
this.#logDebug("GraphileMigrationHelperService.call");
|
||||
|
||||
await this.#detectAndPrepareForMigrations();
|
||||
|
||||
await runMigrations({
|
||||
connectionString: env.DATABASE_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
});
|
||||
}
|
||||
|
||||
#logDebug(message: string, args?: any) {
|
||||
logger.debug(`[migrationHelper] ${message}`, args);
|
||||
}
|
||||
|
||||
async #getLatestMigration() {
|
||||
const migrationQueryResult = await this.#prismaClient.$queryRawUnsafe(`
|
||||
SELECT id FROM ${env.WORKER_SCHEMA}.migrations
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`);
|
||||
|
||||
const MigrationQueryResultSchema = z.array(z.object({ id: z.number() }));
|
||||
|
||||
const migrationResults = MigrationQueryResultSchema.parse(migrationQueryResult);
|
||||
|
||||
if (!migrationResults.length) {
|
||||
// no migrations applied yet
|
||||
return -1;
|
||||
}
|
||||
|
||||
return migrationResults[0].id;
|
||||
}
|
||||
|
||||
async #graphileSchemaExists() {
|
||||
const schemaCount = await this.#prismaClient.$executeRaw`
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name = ${env.WORKER_SCHEMA}
|
||||
`;
|
||||
|
||||
return schemaCount === 1;
|
||||
}
|
||||
|
||||
/** Helper for graphile-worker v0.14.0 migration. No-op if already migrated. */
|
||||
async #detectAndPrepareForMigrations() {
|
||||
if (!(await this.#graphileSchemaExists())) {
|
||||
// no schema yet, likely first start
|
||||
return;
|
||||
}
|
||||
|
||||
const latestMigration = await this.#getLatestMigration();
|
||||
|
||||
if (latestMigration < 0) {
|
||||
// no migrations found
|
||||
return;
|
||||
}
|
||||
|
||||
// the first v0.14.0 migration has ID 11
|
||||
if (latestMigration > 10) {
|
||||
// already migrated
|
||||
return;
|
||||
}
|
||||
|
||||
// add 15s to graceful shutdown timeout, just to be safe
|
||||
const migrationDelayInMs = env.GRACEFUL_SHUTDOWN_TIMEOUT + 15000;
|
||||
|
||||
this.#logDebug("Delaying worker startup due to pending migration", {
|
||||
latestMigration,
|
||||
migrationDelayInMs,
|
||||
});
|
||||
|
||||
console.log(`⚠️ detected pending graphile migration`);
|
||||
console.log(`⚠️ notifying running workers`);
|
||||
|
||||
const pgNotify = new PgNotifyService();
|
||||
await pgNotify.call("trigger:graphile:migrate", { latestMigration });
|
||||
|
||||
console.log(`⚠️ delaying worker startup by ${migrationDelayInMs}ms`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, migrationDelayInMs));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ export class IngestSendEvent {
|
||||
try {
|
||||
const deliverAt = this.#calculateDeliverAt(options);
|
||||
|
||||
if (!environment.organization.runsEnabled) {
|
||||
logger.debug("IngestSendEvent: Runs are disabled for this organization", environment);
|
||||
return;
|
||||
}
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
const externalAccount = options?.accountId
|
||||
? await tx.externalAccount.upsert({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { $transaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
import { createHash } from "node:crypto";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
|
||||
@@ -36,6 +37,13 @@ export class StartRunService {
|
||||
}
|
||||
|
||||
#runIsStartable(run: FoundRun) {
|
||||
if (!run.organization.runsEnabled) {
|
||||
logger.debug("StartRunService: Runs are disabled for this organization", {
|
||||
organizationId: run.organization.id,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const startableStatuses = ["PENDING", "WAITING_ON_CONNECTIONS"] as const;
|
||||
return startableStatuses.includes(run.status);
|
||||
}
|
||||
@@ -64,8 +72,6 @@ export class StartRunService {
|
||||
await $transaction(
|
||||
this.#prismaClient,
|
||||
async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.jobCounter.upsert({
|
||||
where: { jobId: run.jobId },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
@@ -146,6 +152,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
|
||||
include: {
|
||||
queue: true,
|
||||
environment: true,
|
||||
organization: true,
|
||||
version: {
|
||||
include: {
|
||||
integrations: {
|
||||
|
||||
@@ -55,12 +55,14 @@ export class SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
const EncryptedSecretValueSchema = z.object({
|
||||
export const EncryptedSecretValueSchema = z.object({
|
||||
nonce: z.string(),
|
||||
ciphertext: z.string(),
|
||||
tag: z.string(),
|
||||
});
|
||||
|
||||
export type EncryptedSecretValue = z.infer<typeof EncryptedSecretValueSchema>;
|
||||
|
||||
/** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */
|
||||
class PrismaSecretStore implements SecretStoreProvider {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
@@ -181,18 +183,11 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
}
|
||||
|
||||
async #decrypt(nonce: string, ciphertext: string, tag: string): Promise<string> {
|
||||
const decipher = nodeCrypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
this.encryptionKey,
|
||||
Buffer.from(nonce, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
return await decryptSecret(this.encryptionKey, {
|
||||
nonce,
|
||||
ciphertext,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
|
||||
async #encrypt(value: string): Promise<{
|
||||
@@ -200,19 +195,7 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
ciphertext: string;
|
||||
tag: string;
|
||||
}> {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", this.encryptionKey, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
return await encryptSecret(this.encryptionKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,3 +217,40 @@ export function getSecretStore<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptSecret(
|
||||
encryptionKey: string,
|
||||
secret: EncryptedSecretValue
|
||||
): Promise<string> {
|
||||
const decipher = nodeCrypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey,
|
||||
Buffer.from(secret.nonce, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(secret.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(secret.ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
export async function encryptSecret(
|
||||
encryptionKey: string,
|
||||
value: string
|
||||
): Promise<EncryptedSecretValue> {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { workerQueue } from "../worker.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class HandleHttpSourceService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -19,6 +20,7 @@ export class HandleHttpSourceService {
|
||||
endpoint: true,
|
||||
environment: true,
|
||||
secretReference: true,
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,6 +32,13 @@ export class HandleHttpSourceService {
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
if (!triggerSource.organization.runsEnabled) {
|
||||
logger.debug("HandleHttpSourceService: Runs are disabled for this organization", {
|
||||
organizationId: triggerSource.organization.id,
|
||||
});
|
||||
return { status: 404 };
|
||||
}
|
||||
|
||||
if (!triggerSource.interactive) {
|
||||
const sourceRequest = await createHttpSourceRequest(request);
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@ export class HandleWebhookRequestService {
|
||||
const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
|
||||
|
||||
await this.#prismaClient.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.webhookDeliveryCounter.upsert({
|
||||
where: { webhookId: webhookEnvironment.id },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
|
||||
@@ -37,6 +37,10 @@ import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
|
||||
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
|
||||
import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server";
|
||||
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
|
||||
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
|
||||
import { GraphileMigrationHelperService } from "./db/graphileMigrationHelper.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -136,6 +140,15 @@ const workerCatalog = {
|
||||
"v3.triggerScheduledTask": z.object({
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
"v3.performTaskAttemptAlerts": z.object({
|
||||
attemptId: z.string(),
|
||||
}),
|
||||
"v3.deliverAlert": z.object({
|
||||
alertId: z.string(),
|
||||
}),
|
||||
"v3.performDeploymentAlerts": z.object({
|
||||
deploymentId: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
@@ -199,9 +212,8 @@ if (env.NODE_ENV === "production") {
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
// const pgNotify = new PgNotifyService();
|
||||
// await pgNotify.call("trigger:graphile:migrate", { latestMigration: 10 });
|
||||
// await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
const migrationHelper = new GraphileMigrationHelperService();
|
||||
await migrationHelper.call();
|
||||
|
||||
if (env.WORKER_ENABLED === "true") {
|
||||
await workerQueue.initialize();
|
||||
@@ -238,7 +250,7 @@ function getWorkerQueue() {
|
||||
recurringTasks: {
|
||||
// Run this every 5 minutes
|
||||
autoIndexProductionEndpoints: {
|
||||
pattern: "*/5 * * * *",
|
||||
match: "*/5 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
const service = new RecurringEndpointIndexService();
|
||||
|
||||
@@ -247,7 +259,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
// Run this every hour
|
||||
purgeOldIndexings: {
|
||||
pattern: "0 * * * *",
|
||||
match: "0 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
// Delete indexings that are older than 7 days
|
||||
await prisma.endpointIndex.deleteMany({
|
||||
@@ -261,7 +273,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
// Run this every hour at the 13 minute mark
|
||||
purgeOldTaskEvents: {
|
||||
pattern: "47 * * * *",
|
||||
match: "47 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
await eventRepository.truncateEvents();
|
||||
},
|
||||
@@ -533,6 +545,33 @@ function getWorkerQueue() {
|
||||
return await service.call(payload.instanceId);
|
||||
},
|
||||
},
|
||||
"v3.performTaskAttemptAlerts": {
|
||||
priority: 0,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformTaskAttemptAlertsService();
|
||||
|
||||
return await service.call(payload.attemptId);
|
||||
},
|
||||
},
|
||||
"v3.deliverAlert": {
|
||||
priority: 0,
|
||||
maxAttempts: 8,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverAlertService();
|
||||
|
||||
return await service.call(payload.alertId);
|
||||
},
|
||||
},
|
||||
"v3.performDeploymentAlerts": {
|
||||
priority: 0,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformDeploymentAlertsService();
|
||||
|
||||
return await service.call(payload.deploymentId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const environmentSortOrder: RuntimeEnvironmentType[] = [
|
||||
"DEVELOPMENT",
|
||||
@@ -325,6 +325,21 @@ export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project:
|
||||
return `${v3EnvironmentVariablesPath(organization, project)}/new`;
|
||||
}
|
||||
|
||||
export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/alerts`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectAlertsPath(organization, project)}/new`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPathConnectToSlackPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath
|
||||
) {
|
||||
return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`;
|
||||
}
|
||||
|
||||
export function v3TestPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database";
|
||||
import { nanoid } from "nanoid";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import { encryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService, ServiceValidationError } from "../baseService.server";
|
||||
|
||||
export type CreateAlertChannelOptions = {
|
||||
name: string;
|
||||
alertTypes: ProjectAlertType[];
|
||||
deduplicationKey?: string;
|
||||
channel:
|
||||
| {
|
||||
type: "EMAIL";
|
||||
email: string;
|
||||
}
|
||||
| {
|
||||
type: "WEBHOOK";
|
||||
url: string;
|
||||
secret?: string;
|
||||
}
|
||||
| {
|
||||
type: "SLACK";
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
integrationId: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export class CreateAlertChannelService extends BaseService {
|
||||
public async call(
|
||||
projectRef: string,
|
||||
userId: string,
|
||||
options: CreateAlertChannelOptions
|
||||
): Promise<ProjectAlertChannel> {
|
||||
const project = await findProjectByRef(projectRef, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new ServiceValidationError("Project not found");
|
||||
}
|
||||
|
||||
const existingAlertChannel = options.deduplicationKey
|
||||
? await this._prisma.projectAlertChannel.findUnique({
|
||||
where: {
|
||||
projectId_deduplicationKey: {
|
||||
projectId: project.id,
|
||||
deduplicationKey: options.deduplicationKey,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (existingAlertChannel) {
|
||||
return await this._prisma.projectAlertChannel.update({
|
||||
where: { id: existingAlertChannel.id },
|
||||
data: {
|
||||
name: options.name,
|
||||
alertTypes: options.alertTypes,
|
||||
type: options.channel.type,
|
||||
properties: await this.#createProperties(options.channel),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const alertChannel = await this._prisma.projectAlertChannel.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert_channel"),
|
||||
name: options.name,
|
||||
alertTypes: options.alertTypes,
|
||||
projectId: project.id,
|
||||
type: options.channel.type,
|
||||
properties: await this.#createProperties(options.channel),
|
||||
enabled: true,
|
||||
deduplicationKey: options.deduplicationKey,
|
||||
userProvidedDeduplicationKey: options.deduplicationKey ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
return alertChannel;
|
||||
}
|
||||
|
||||
async #createProperties(channel: CreateAlertChannelOptions["channel"]) {
|
||||
switch (channel.type) {
|
||||
case "EMAIL":
|
||||
return {
|
||||
email: channel.email,
|
||||
};
|
||||
case "WEBHOOK":
|
||||
return {
|
||||
url: channel.url,
|
||||
secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()),
|
||||
};
|
||||
case "SLACK":
|
||||
return {
|
||||
channelId: channel.channelId,
|
||||
channelName: channel.channelName,
|
||||
integrationId: channel.integrationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3";
|
||||
import assertNever from "assert-never";
|
||||
import { subtle } from "crypto";
|
||||
import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertSlackProperties,
|
||||
ProjectAlertSlackStorage,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||||
import { sendEmail } from "~/services/email.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
|
||||
type FoundAlert = Prisma.Result<
|
||||
typeof prisma.projectAlert,
|
||||
{
|
||||
include: {
|
||||
channel: true;
|
||||
project: {
|
||||
include: {
|
||||
organization: true;
|
||||
};
|
||||
};
|
||||
environment: true;
|
||||
taskRunAttempt: {
|
||||
include: {
|
||||
taskRun: true;
|
||||
backgroundWorkerTask: true;
|
||||
backgroundWorker: true;
|
||||
};
|
||||
};
|
||||
workerDeployment: {
|
||||
include: {
|
||||
worker: {
|
||||
include: {
|
||||
tasks: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
"findUniqueOrThrow"
|
||||
>;
|
||||
|
||||
export class DeliverAlertService extends BaseService {
|
||||
public async call(alertId: string) {
|
||||
const alert = await this._prisma.projectAlert.findUnique({
|
||||
where: { id: alertId },
|
||||
include: {
|
||||
channel: true,
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
environment: true,
|
||||
taskRunAttempt: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
backgroundWorkerTask: true,
|
||||
backgroundWorker: true,
|
||||
},
|
||||
},
|
||||
workerDeployment: {
|
||||
include: {
|
||||
worker: {
|
||||
include: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alert.status !== "PENDING") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alert.environment.type === "DEVELOPMENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.channel.type) {
|
||||
case "EMAIL": {
|
||||
await this.#sendEmail(alert);
|
||||
break;
|
||||
}
|
||||
case "SLACK": {
|
||||
await this.#sendSlack(alert);
|
||||
break;
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
await this.#sendWebhook(alert);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.channel.type);
|
||||
}
|
||||
}
|
||||
|
||||
await this._prisma.projectAlert.update({
|
||||
where: { id: alertId },
|
||||
data: {
|
||||
status: "SENT",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #sendEmail(alert: FoundAlert) {
|
||||
const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!emailProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse email properties", {
|
||||
issues: emailProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: "alert-attempt",
|
||||
to: emailProperties.data.email,
|
||||
taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier,
|
||||
fileName: alert.taskRunAttempt.backgroundWorkerTask.filePath,
|
||||
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
|
||||
version: alert.taskRunAttempt.backgroundWorker.version,
|
||||
environment: alert.environment.slug,
|
||||
error: createJsonErrorObject(taskRunError.data),
|
||||
attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: "alert-deployment-failure",
|
||||
to: emailProperties.data.email,
|
||||
version: alert.workerDeployment.version,
|
||||
environment: alert.environment.slug,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
failedAt: alert.workerDeployment.failedAt ?? new Date(),
|
||||
error: preparedError,
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
await sendEmail({
|
||||
email: "alert-deployment-success",
|
||||
to: emailProperties.data.email,
|
||||
version: alert.workerDeployment.version,
|
||||
environment: alert.environment.slug,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendWebhook(alert: FoundAlert) {
|
||||
const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!webhookProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse webhook properties", {
|
||||
issues: webhookProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = createJsonErrorObject(taskRunError.data);
|
||||
|
||||
const payload = {
|
||||
task: {
|
||||
id: alert.taskRunAttempt.taskRun.taskIdentifier,
|
||||
filePath: alert.taskRunAttempt.backgroundWorkerTask.filePath,
|
||||
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
|
||||
},
|
||||
attempt: {
|
||||
id: alert.taskRunAttempt.friendlyId,
|
||||
number: alert.taskRunAttempt.number,
|
||||
startedAt: alert.taskRunAttempt.startedAt,
|
||||
status: alert.taskRunAttempt.status,
|
||||
},
|
||||
run: {
|
||||
id: alert.taskRunAttempt.taskRun.friendlyId,
|
||||
isTest: alert.taskRunAttempt.taskRun.isTest,
|
||||
createdAt: alert.taskRunAttempt.taskRun.createdAt,
|
||||
idempotencyKey: alert.taskRunAttempt.taskRun.idempotencyKey,
|
||||
},
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
error,
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
deployment: {
|
||||
id: alert.workerDeployment.friendlyId,
|
||||
status: alert.workerDeployment.status,
|
||||
version: alert.workerDeployment.version,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
failedAt: alert.workerDeployment.failedAt ?? new Date(),
|
||||
},
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
error: preparedError,
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
const payload = {
|
||||
deployment: {
|
||||
id: alert.workerDeployment.friendlyId,
|
||||
status: alert.workerDeployment.status,
|
||||
version: alert.workerDeployment.version,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
|
||||
},
|
||||
tasks:
|
||||
alert.workerDeployment.worker?.tasks.map((task) => ({
|
||||
id: task.slug,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
triggerSource: task.triggerSource,
|
||||
})) ?? [],
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendSlack(alert: FoundAlert) {
|
||||
const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!slackProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse slack properties", {
|
||||
issues: slackProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the org integration
|
||||
const integration = slackProperties.data.integrationId
|
||||
? await this._prisma.organizationIntegration.findUnique({
|
||||
where: {
|
||||
id: slackProperties.data.integrationId,
|
||||
organizationId: alert.project.organizationId,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
})
|
||||
: await this._prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: alert.project.organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.error("[DeliverAlert] Slack integration not found", {
|
||||
alert,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the client
|
||||
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
|
||||
integration,
|
||||
{ forceBotToken: true }
|
||||
);
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
// Find existing storage by the run ID
|
||||
const storage = await this._prisma.projectAlertStorage.findFirst({
|
||||
where: {
|
||||
alertChannelId: alert.channel.id,
|
||||
alertType: alert.type,
|
||||
storageId: alert.taskRunAttempt.taskRunId,
|
||||
},
|
||||
});
|
||||
|
||||
const storageData = storage
|
||||
? ProjectAlertSlackStorage.safeParse(storage.storageData)
|
||||
: undefined;
|
||||
|
||||
const thread_ts =
|
||||
storageData && storageData.success ? storageData.data.message_ts : undefined;
|
||||
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = createJsonErrorObject(taskRunError.data);
|
||||
|
||||
const exportName = alert.taskRunAttempt.backgroundWorkerTask.exportName;
|
||||
const version = alert.taskRunAttempt.backgroundWorker.version;
|
||||
const environment = alert.environment.slug;
|
||||
const taskIdentifier = alert.taskRunAttempt.backgroundWorkerTask.slug;
|
||||
const timestamp = alert.taskRunAttempt.completedAt ?? new Date();
|
||||
const runId = alert.taskRunAttempt.taskRun.friendlyId;
|
||||
const attemptNumber = alert.taskRunAttempt.number;
|
||||
|
||||
try {
|
||||
const message = await client.chat.postMessage({
|
||||
thread_ts,
|
||||
channel: slackProperties.data.channelId,
|
||||
text: `Task error in ${alert.taskRunAttempt.backgroundWorkerTask.exportName} [${alert.taskRunAttempt.backgroundWorker.version}.${alert.environment.slug}]`,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Error in *${exportName}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `\`\`\`${error.stackTrace ?? error.message}\`\`\``,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Investigate",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Upsert the storage
|
||||
if (message.ts) {
|
||||
if (storage) {
|
||||
await this._prisma.projectAlertStorage.update({
|
||||
where: {
|
||||
id: storage.id,
|
||||
},
|
||||
data: {
|
||||
storageData: {
|
||||
message_ts: message.ts,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this._prisma.projectAlertStorage.create({
|
||||
data: {
|
||||
alertChannelId: alert.channel.id,
|
||||
alertType: alert.type,
|
||||
storageId: alert.taskRunAttempt.taskRunId,
|
||||
storageData: {
|
||||
message_ts: message.ts,
|
||||
},
|
||||
projectId: alert.project.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[DeliverAlert] Failed to send slack message", {
|
||||
error,
|
||||
alert,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const version = alert.workerDeployment.version;
|
||||
const environment = alert.environment.slug;
|
||||
const timestamp = alert.workerDeployment.failedAt ?? new Date();
|
||||
|
||||
try {
|
||||
await client.chat.postMessage({
|
||||
channel: slackProperties.data.channelId,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Deployment failed *${version}.${environment}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View Deployment",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[DeliverAlert] Failed to send slack message", {
|
||||
error,
|
||||
alert,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
const version = alert.workerDeployment.version;
|
||||
const environment = alert.environment.slug;
|
||||
const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0;
|
||||
const timestamp = alert.workerDeployment.deployedAt ?? new Date();
|
||||
|
||||
await client.chat.postMessage({
|
||||
channel: slackProperties.data.channelId,
|
||||
text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rocket: Deployed *${version}.${environment}* successfully _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View Deployment",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return;
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) {
|
||||
const rawPayload = JSON.stringify(payload);
|
||||
const hashPayload = Buffer.from(rawPayload, "utf-8");
|
||||
|
||||
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhook.secret);
|
||||
|
||||
const hmacSecret = Buffer.from(secret, "utf-8");
|
||||
const key = await subtle.importKey(
|
||||
"raw",
|
||||
hmacSecret,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await subtle.sign("HMAC", key, hashPayload);
|
||||
const signatureHex = Buffer.from(signature).toString("hex");
|
||||
|
||||
// Send the webhook to the URL specified in webhook.url
|
||||
const response = await fetch(webhook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trigger-signature-hmacsha256": signatureHex,
|
||||
},
|
||||
body: rawPayload,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error("[DeliverAlert] Failed to send alert webhook", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: webhook.url,
|
||||
body: payload,
|
||||
signature,
|
||||
});
|
||||
|
||||
throw new Error(`Failed to send alert webhook to ${webhook.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async enqueue(
|
||||
alertId: string,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options?: { runAt?: Date; queueName?: string }
|
||||
) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.deliverAlert",
|
||||
{
|
||||
alertId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: options?.runAt,
|
||||
jobKey: `deliverAlert:${alertId}`,
|
||||
queueName: options?.queueName,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ProjectAlertChannel, ProjectAlertType, WorkerDeployment } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { DeliverAlertService } from "./deliverAlert.server";
|
||||
|
||||
export class PerformDeploymentAlertsService extends BaseService {
|
||||
public async call(deploymentId: string) {
|
||||
const deployment = await this._prisma.workerDeployment.findUnique({
|
||||
where: { id: deploymentId },
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertType =
|
||||
deployment.status === "DEPLOYED" ? "DEPLOYMENT_SUCCESS" : "DEPLOYMENT_FAILURE";
|
||||
|
||||
// Find all the alert channels
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId: deployment.projectId,
|
||||
alertTypes: {
|
||||
has: alertType,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const alertChannel of alertChannels) {
|
||||
await this.#createAndSendAlert(alertChannel, deployment, alertType);
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndSendAlert(
|
||||
alertChannel: ProjectAlertChannel,
|
||||
deployment: WorkerDeployment,
|
||||
alertType: ProjectAlertType
|
||||
) {
|
||||
await $transaction(this._prisma, async (tx) => {
|
||||
const alert = await this._prisma.projectAlert.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert"),
|
||||
channelId: alertChannel.id,
|
||||
projectId: deployment.projectId,
|
||||
environmentId: deployment.environmentId,
|
||||
status: "PENDING",
|
||||
type: alertType,
|
||||
workerDeploymentId: deployment.id,
|
||||
},
|
||||
});
|
||||
|
||||
await DeliverAlertService.enqueue(alert.id, tx, {
|
||||
queueName: `alert-channel:${alertChannel.id}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async enqueue(deploymentId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.performDeploymentAlerts",
|
||||
{
|
||||
deploymentId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `performDeploymentAlerts:${deploymentId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Prisma, ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { DeliverAlertService } from "./deliverAlert.server";
|
||||
|
||||
type FoundTaskAttempt = Prisma.Result<
|
||||
typeof prisma.taskRunAttempt,
|
||||
{ include: { taskRun: true; backgroundWorkerTask: true; runtimeEnvironment: true } },
|
||||
"findUniqueOrThrow"
|
||||
>;
|
||||
|
||||
export class PerformTaskAttemptAlertsService extends BaseService {
|
||||
public async call(attemptId: string) {
|
||||
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { id: attemptId },
|
||||
include: {
|
||||
taskRun: true,
|
||||
backgroundWorkerTask: true,
|
||||
runtimeEnvironment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskAttempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all the alert channels
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId: taskAttempt.taskRun.projectId,
|
||||
alertTypes: {
|
||||
has: "TASK_RUN_ATTEMPT",
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const alertChannel of alertChannels) {
|
||||
await this.#createAndSendAlert(alertChannel, taskAttempt);
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndSendAlert(alertChannel: ProjectAlertChannel, taskAttempt: FoundTaskAttempt) {
|
||||
await $transaction(this._prisma, async (tx) => {
|
||||
const alert = await this._prisma.projectAlert.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert"),
|
||||
channelId: alertChannel.id,
|
||||
projectId: taskAttempt.taskRun.projectId,
|
||||
environmentId: taskAttempt.runtimeEnvironmentId,
|
||||
status: "PENDING",
|
||||
type: "TASK_RUN_ATTEMPT",
|
||||
taskRunAttemptId: taskAttempt.id,
|
||||
},
|
||||
});
|
||||
|
||||
await DeliverAlertService.enqueue(alert.id, tx, {
|
||||
queueName: `alert-channel:${alertChannel.id}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.performTaskAttemptAlerts",
|
||||
{
|
||||
attemptId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `performTaskAttemptAlerts:${attemptId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.se
|
||||
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
|
||||
import { CreateCheckpointService } from "./createCheckpoint.server";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
|
||||
|
||||
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
|
||||
|
||||
@@ -154,9 +155,13 @@ export class CompleteAttemptService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
|
||||
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
|
||||
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
|
||||
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
await PerformTaskAttemptAlertsService.enqueue(taskRunAttempt.id, this._prisma);
|
||||
}
|
||||
|
||||
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
|
||||
const retryAt = new Date(completion.retry.timestamp);
|
||||
|
||||
// Retry the task run
|
||||
|
||||
@@ -9,6 +9,7 @@ import { projectPubSub } from "./projectPubSub.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
|
||||
export class CreateDeployedBackgroundWorkerService extends BaseService {
|
||||
public async call(
|
||||
@@ -98,6 +99,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
|
||||
}
|
||||
|
||||
await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma);
|
||||
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
|
||||
|
||||
return backgroundWorker;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { OrganizationIntegration } from "@trigger.dev/database";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { env } from "~/env.server";
|
||||
import { $transaction } from "~/db.server";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
|
||||
export class CreateOrgIntegrationService extends BaseService {
|
||||
public async call(
|
||||
userId: string,
|
||||
orgId: string,
|
||||
serviceName: string,
|
||||
code: string
|
||||
): Promise<OrganizationIntegration | undefined> {
|
||||
// Get the org
|
||||
const org = await this._prisma.organization.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
return OrgIntegrationRepository.createOrgIntegration(serviceName, code, org);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
|
||||
export class DeploymentIndexFailed extends BaseService {
|
||||
@@ -22,6 +23,8 @@ export class DeploymentIndexFailed extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
|
||||
|
||||
return deployment;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ export class StartDeploymentIndexing extends BaseService {
|
||||
friendlyId: deploymentId,
|
||||
},
|
||||
data: {
|
||||
imageReference: registryProxy
|
||||
? registryProxy.rewriteImageReference(body.imageReference)
|
||||
: body.imageReference,
|
||||
imageReference:
|
||||
registryProxy && body.selfHosted !== true
|
||||
? registryProxy.rewriteImageReference(body.imageReference)
|
||||
: body.imageReference,
|
||||
status: "DEPLOYING",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
|
||||
export class TimeoutDeploymentService extends BaseService {
|
||||
public async call(id: string, fromStatus: string, errorMessage: string) {
|
||||
@@ -32,6 +33,8 @@ export class TimeoutDeploymentService extends BaseService {
|
||||
errorData: { message: errorMessage, name: "TimeoutError" },
|
||||
},
|
||||
});
|
||||
|
||||
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
|
||||
}
|
||||
|
||||
static async enqueue(
|
||||
|
||||
@@ -88,8 +88,6 @@ export class TriggerTaskService extends BaseService {
|
||||
const lockId = taskIdentifierToLockId(taskId);
|
||||
|
||||
const run = await $transaction(this._prisma, async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await tx.backgroundWorker.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"@remix-run/serve": "2.1.0",
|
||||
"@remix-run/server-runtime": "2.1.0",
|
||||
"@remix-run/v1-meta": "^0.1.3",
|
||||
"@slack/web-api": "^6.8.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
@@ -115,7 +116,7 @@
|
||||
"evt": "^2.4.13",
|
||||
"express": "^4.18.1",
|
||||
"framer-motion": "^10.12.11",
|
||||
"graphile-worker": "^0.13.0",
|
||||
"graphile-worker": "0.16.6",
|
||||
"highlight.run": "^7.3.4",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"intl-parse-accept-language": "^1.0.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
+54
-110
@@ -1,14 +1,8 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/schema.json",
|
||||
"name": "Trigger.dev",
|
||||
"openapi": [
|
||||
"/openapi.yml",
|
||||
"/v3-openapi.json"
|
||||
],
|
||||
"versions": [
|
||||
"v3 (Developer Preview)",
|
||||
"v2"
|
||||
],
|
||||
"openapi": ["/openapi.yml", "/v3-openapi.json"],
|
||||
"versions": ["v3 (Developer Preview)", "v2"],
|
||||
"logo": {
|
||||
"dark": "/logo/dark.png",
|
||||
"light": "/logo/light.png",
|
||||
@@ -48,16 +42,6 @@
|
||||
"name": "Home"
|
||||
},
|
||||
"tabs": [
|
||||
{
|
||||
"name": "v3 Developer Preview",
|
||||
"url": "https://trigger.dev/docs/v3",
|
||||
"version": "v2"
|
||||
},
|
||||
{
|
||||
"name": "v2",
|
||||
"url": "https://trigger.dev/docs/documentation",
|
||||
"version": "v3 (Developer Preview)"
|
||||
},
|
||||
{
|
||||
"name": "Integrations",
|
||||
"url": "integrations",
|
||||
@@ -72,6 +56,16 @@
|
||||
"name": "Examples",
|
||||
"url": "https://trigger.dev/apis",
|
||||
"version": "v2"
|
||||
},
|
||||
{
|
||||
"name": "v3 Developer Preview",
|
||||
"url": "https://trigger.dev/docs/v3",
|
||||
"version": "v2"
|
||||
},
|
||||
{
|
||||
"name": "v2",
|
||||
"url": "https://trigger.dev/docs/documentation",
|
||||
"version": "v3 (Developer Preview)"
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
@@ -96,9 +90,7 @@
|
||||
{
|
||||
"group": "",
|
||||
"version": "v3 (Developer Preview)",
|
||||
"pages": [
|
||||
"v3/introduction"
|
||||
]
|
||||
"pages": ["v3/introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Getting Started",
|
||||
@@ -121,12 +113,7 @@
|
||||
"v3/apikeys",
|
||||
{
|
||||
"group": "Task types",
|
||||
"pages": [
|
||||
"v3/tasks-regular",
|
||||
"v3/tasks-scheduled",
|
||||
"v3/tasks-zod",
|
||||
"v3/tasks-webhooks"
|
||||
]
|
||||
"pages": ["v3/tasks-regular", "v3/tasks-scheduled", "v3/tasks-zod", "v3/tasks-webhooks"]
|
||||
},
|
||||
"v3/trigger-config"
|
||||
]
|
||||
@@ -134,10 +121,7 @@
|
||||
{
|
||||
"group": "Development",
|
||||
"version": "v3 (Developer Preview)",
|
||||
"pages": [
|
||||
"v3/cli-dev",
|
||||
"v3/run-tests"
|
||||
]
|
||||
"pages": ["v3/cli-dev", "v3/run-tests"]
|
||||
},
|
||||
{
|
||||
"group": "Deployment",
|
||||
@@ -148,9 +132,7 @@
|
||||
"v3/github-actions",
|
||||
{
|
||||
"group": "Deployment integrations",
|
||||
"pages": [
|
||||
"v3/vercel-integration"
|
||||
]
|
||||
"pages": ["v3/vercel-integration"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -184,20 +166,30 @@
|
||||
"v3/automated-tests"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Dashboard",
|
||||
"version": "v3 (Developer Preview)",
|
||||
"pages": [
|
||||
"v3/dashboard-overview",
|
||||
"v3/dashboard-runs",
|
||||
"v3/dashboard-tests",
|
||||
"v3/dashboard-environment-variables"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "API reference",
|
||||
"version": "v3 (Developer Preview)",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Runs API",
|
||||
"pages": [
|
||||
"v3/management-retrieve-run",
|
||||
"v3/management-replay-run",
|
||||
"v3/management-cancel-run"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Schedules API",
|
||||
"pages": [
|
||||
"v3/management-list-schedules",
|
||||
"v3/management-create-schedule",
|
||||
"v3/management-retrieve-schedule",
|
||||
"v3/management-update-schedule",
|
||||
"v3/management-delete-schedule",
|
||||
"v3/management-deactivate-schedule",
|
||||
"v3/management-activate-schedule"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Functions",
|
||||
"pages": [
|
||||
@@ -226,9 +218,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Objects",
|
||||
"pages": [
|
||||
"v3/reference-context"
|
||||
]
|
||||
"pages": ["v3/reference-context"]
|
||||
},
|
||||
{
|
||||
"group": "CLI",
|
||||
@@ -242,26 +232,6 @@
|
||||
"v3/reference-cli-build",
|
||||
"v3/reference-cli-who-am-i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Runs API",
|
||||
"pages": [
|
||||
"v3/management-retrieve-run",
|
||||
"v3/management-replay-run",
|
||||
"v3/management-cancel-run"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Schedules API",
|
||||
"pages": [
|
||||
"v3/management-list-schedules",
|
||||
"v3/management-create-schedule",
|
||||
"v3/management-retrieve-schedule",
|
||||
"v3/management-update-schedule",
|
||||
"v3/management-delete-schedule",
|
||||
"v3/management-deactivate-schedule",
|
||||
"v3/management-activate-schedule"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -277,11 +247,7 @@
|
||||
{
|
||||
"group": "Open source",
|
||||
"version": "v3 (Developer Preview)",
|
||||
"pages": [
|
||||
"v3/github-repo",
|
||||
"v3/open-source-self-hosting",
|
||||
"v3/open-source-contributing"
|
||||
]
|
||||
"pages": ["v3/github-repo", "v3/open-source-self-hosting", "v3/open-source-contributing"]
|
||||
},
|
||||
{
|
||||
"group": "Help",
|
||||
@@ -459,6 +425,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Overview",
|
||||
"version": "v2",
|
||||
"pages": [
|
||||
"integrations/introduction",
|
||||
{
|
||||
@@ -479,13 +446,11 @@
|
||||
},
|
||||
{
|
||||
"group": "Integrations",
|
||||
"version": "v2",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Airtable",
|
||||
"pages": [
|
||||
"integrations/apis/airtable",
|
||||
"integrations/apis/airtable-tasks"
|
||||
]
|
||||
"pages": ["integrations/apis/airtable", "integrations/apis/airtable-tasks"]
|
||||
},
|
||||
{
|
||||
"group": "GitHub",
|
||||
@@ -511,25 +476,16 @@
|
||||
},
|
||||
{
|
||||
"group": "Plain",
|
||||
"pages": [
|
||||
"integrations/apis/plain",
|
||||
"integrations/apis/plain-tasks"
|
||||
]
|
||||
"pages": ["integrations/apis/plain", "integrations/apis/plain-tasks"]
|
||||
},
|
||||
"integrations/apis/replicate",
|
||||
{
|
||||
"group": "SendGrid",
|
||||
"pages": [
|
||||
"integrations/apis/sendgrid",
|
||||
"integrations/apis/sendgrid-tasks"
|
||||
]
|
||||
"pages": ["integrations/apis/sendgrid", "integrations/apis/sendgrid-tasks"]
|
||||
},
|
||||
{
|
||||
"group": "Resend",
|
||||
"pages": [
|
||||
"integrations/apis/resend",
|
||||
"integrations/apis/resend-tasks"
|
||||
]
|
||||
"pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"]
|
||||
},
|
||||
{
|
||||
"group": "Shopify",
|
||||
@@ -541,10 +497,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Slack",
|
||||
"pages": [
|
||||
"integrations/apis/slack",
|
||||
"integrations/apis/slack-tasks"
|
||||
]
|
||||
"pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"]
|
||||
},
|
||||
"integrations/apis/stripe",
|
||||
{
|
||||
@@ -560,6 +513,7 @@
|
||||
},
|
||||
{
|
||||
"group": "SDK",
|
||||
"version": "v2",
|
||||
"pages": [
|
||||
"sdk/introduction",
|
||||
{
|
||||
@@ -569,9 +523,7 @@
|
||||
"sdk/triggerclient/constructor",
|
||||
{
|
||||
"group": "Instance properties",
|
||||
"pages": [
|
||||
"sdk/triggerclient/store"
|
||||
]
|
||||
"pages": ["sdk/triggerclient/store"]
|
||||
},
|
||||
{
|
||||
"group": "Instance methods",
|
||||
@@ -634,10 +586,7 @@
|
||||
"sdk/dynamictrigger/constructor",
|
||||
{
|
||||
"group": "Instance methods",
|
||||
"pages": [
|
||||
"sdk/dynamictrigger/register",
|
||||
"sdk/dynamictrigger/unregister"
|
||||
]
|
||||
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -648,10 +597,7 @@
|
||||
"sdk/dynamicschedule/constructor",
|
||||
{
|
||||
"group": "Instance methods",
|
||||
"pages": [
|
||||
"sdk/dynamicschedule/register",
|
||||
"sdk/dynamicschedule/unregister"
|
||||
]
|
||||
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -663,12 +609,12 @@
|
||||
},
|
||||
{
|
||||
"group": "HTTP Reference",
|
||||
"pages": [
|
||||
"sdk/api-reference/events/create-an-event"
|
||||
]
|
||||
"version": "v2",
|
||||
"pages": ["sdk/api-reference/events/create-an-event"]
|
||||
},
|
||||
{
|
||||
"group": "React SDK",
|
||||
"version": "v2",
|
||||
"pages": [
|
||||
"sdk/react/introduction",
|
||||
"sdk/react/triggerprovider",
|
||||
@@ -680,9 +626,7 @@
|
||||
{
|
||||
"group": "Overview",
|
||||
"version": "v2",
|
||||
"pages": [
|
||||
"examples/introduction"
|
||||
]
|
||||
"pages": ["examples/introduction"]
|
||||
}
|
||||
],
|
||||
"footerSocials": {
|
||||
@@ -690,4 +634,4 @@
|
||||
"github": "https://github.com/triggerdotdev",
|
||||
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-19
@@ -3,24 +3,24 @@ title: "Feature matrix"
|
||||
description: "What features are currently available in the Developer Preview"
|
||||
---
|
||||
|
||||
| Feature | Description | Status |
|
||||
| ----------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
|
||||
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
|
||||
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
|
||||
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
|
||||
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
|
||||
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
|
||||
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
|
||||
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
|
||||
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
|
||||
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
|
||||
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ |
|
||||
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ |
|
||||
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
|
||||
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
|
||||
| Logs view with search | All logs view with filtering and full text search | ⏳ |
|
||||
| Alerts | Add alerts in the UI for errors and queue backlogs | ⏳ |
|
||||
| Notifications | Send data to your web app from a run | ⏳ |
|
||||
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
|
||||
| Feature | Description | Status |
|
||||
| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ------ |
|
||||
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
|
||||
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
|
||||
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
|
||||
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
|
||||
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
|
||||
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
|
||||
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
|
||||
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
|
||||
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
|
||||
| Alerts | Add alerts in the UI for errors and deploys | ✅ |
|
||||
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ |
|
||||
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ |
|
||||
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
|
||||
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
|
||||
| Logs view with search | All logs view with filtering and full text search | ⏳ |
|
||||
| Notifications | Send data to your web app from a run | ⏳ |
|
||||
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
|
||||
|
||||
[Let us know](https://trigger.dev/discord) what we should prioritize and what we are missing.
|
||||
|
||||
@@ -5,7 +5,10 @@ description: "You can easily deploy your tasks with GitHub actions."
|
||||
|
||||
This simple GitHub action file will deploy you Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.
|
||||
|
||||
<Warning>The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details.</Warning>
|
||||
<Warning>
|
||||
The deploy step will fail if any version mismatches are detected. Please see the [version
|
||||
pinning](/v3/github-actions#version-pinning) section for more details.
|
||||
</Warning>
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -41,7 +44,6 @@ jobs:
|
||||
npx trigger.dev@beta deploy
|
||||
```
|
||||
|
||||
|
||||
```yaml .github/workflows/release-trigger-staging.yml
|
||||
name: Deploy to Trigger.dev (staging)
|
||||
|
||||
@@ -60,7 +62,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -70,6 +72,7 @@ jobs:
|
||||
run: |
|
||||
npx trigger.dev@beta deploy --env staging
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.
|
||||
@@ -78,6 +81,17 @@ You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can cr
|
||||
|
||||
To set it in GitHub go to your repository, click on "Settings", "Secrets and variables" and then "Actions". Add a new secret with the name `TRIGGER_ACCESS_TOKEN` and use the value of your access token.
|
||||
|
||||
<Accordion title="How to add TRIGGER_ACCESS_TOKEN in GitHub">
|
||||
1. Go to your repository on GitHub.
|
||||
2. Click on "Settings".
|
||||
3. Click on "Secrets and variables" -> "Actions"
|
||||
4. Click on "New repository secret".
|
||||
5. Add the name `TRIGGER_ACCESS_TOKEN` and the value of your access token.
|
||||
|
||||

|
||||
|
||||
</Accordion>
|
||||
|
||||
## Version pinning
|
||||
|
||||
The CLI and `@trigger.dev/*` package versions need to be in sync, otherwise there will be errors and unpredictable behavior. Hence, the `deploy` command will automatically fail during CI on any version mismatches.
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
|
||||
export type ParserZodEsque<TInput, TParsedInput> = {
|
||||
_input: TInput;
|
||||
_output: TParsedInput;
|
||||
};
|
||||
|
||||
export type ParserMyZodEsque<TInput> = {
|
||||
parse: (input: any) => TInput;
|
||||
};
|
||||
|
||||
export type ParserSuperstructEsque<TInput> = {
|
||||
create: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
|
||||
|
||||
export type ParserYupEsque<TInput> = {
|
||||
validateSync: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserScaleEsque<TInput> = {
|
||||
assert(value: unknown): asserts value is TInput;
|
||||
};
|
||||
|
||||
export type ParserWithoutInput<TInput> =
|
||||
| ParserCustomValidatorEsque<TInput>
|
||||
| ParserMyZodEsque<TInput>
|
||||
| ParserScaleEsque<TInput>
|
||||
| ParserSuperstructEsque<TInput>
|
||||
| ParserYupEsque<TInput>;
|
||||
|
||||
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
|
||||
|
||||
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
|
||||
|
||||
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
|
||||
infer $TIn,
|
||||
infer $TOut
|
||||
>
|
||||
? {
|
||||
in: $TIn;
|
||||
out: $TOut;
|
||||
}
|
||||
: TParser extends ParserWithoutInput<infer $InOut>
|
||||
? {
|
||||
in: $InOut;
|
||||
out: $InOut;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
|
||||
|
||||
export type TriggerResult = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TaskRunResult<TOutput = any> =
|
||||
| {
|
||||
ok: true;
|
||||
id: string;
|
||||
output: TOutput;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
id: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type RunMetadata = {
|
||||
run: string;
|
||||
};
|
||||
|
||||
export type inferContext<TContextBuilder extends AnyContextBuilder> =
|
||||
TContextBuilder extends ContextBuilder<infer TContext, infer TContextOverrides>
|
||||
? TContext extends UnsetMarker
|
||||
? unknown
|
||||
: TContextOverrides extends UnsetMarker
|
||||
? Simplify<TContext>
|
||||
: Simplify<Overwrite<TContext, TContextOverrides>>
|
||||
: never;
|
||||
|
||||
export type RunFnParams<TPayload, TContext extends AnyContextBuilder> = {
|
||||
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
|
||||
meta: RunMetadata;
|
||||
|
||||
/** Context added by task middleware */
|
||||
ctx: inferContext<TContext>;
|
||||
|
||||
payload: TPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* See https://github.com/microsoft/TypeScript/issues/41966#issuecomment-758187996
|
||||
* Fixes issues with iterating over keys of objects with index signatures.
|
||||
* Without this, iterations over keys of objects with index signatures will lose
|
||||
* type information about the keys and only the index signature will remain.
|
||||
* @internal
|
||||
*/
|
||||
export type WithoutIndexSignature<TObj> = {
|
||||
[K in keyof TObj as string extends K ? never : number extends K ? never : K]: TObj[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Overwrite properties in `TType` with properties in `TWith`
|
||||
* Only overwrites properties when the type to be overwritten
|
||||
* is an object. Otherwise it will just use the type from `TWith`.
|
||||
*/
|
||||
export type Overwrite<TType, TWith> = TWith extends any
|
||||
? TType extends object
|
||||
? {
|
||||
[K in
|
||||
| keyof WithoutIndexSignature<TType>
|
||||
| keyof WithoutIndexSignature<TWith>]: K extends keyof TWith // Exclude index signature from keys
|
||||
? TWith[K]
|
||||
: K extends keyof TType
|
||||
? TType[K]
|
||||
: never;
|
||||
} & (string extends keyof TWith // Handle cases with an index signature
|
||||
? { [key: string]: TWith[string] }
|
||||
: number extends keyof TWith
|
||||
? { [key: number]: TWith[number] }
|
||||
: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{})
|
||||
: TWith
|
||||
: never;
|
||||
|
||||
/** @internal */
|
||||
export const contextMiddlewareMarker = "contextMiddlewareMarker" as "contextMiddlewareMarker" & {
|
||||
__brand: "contextMiddlewareMarker";
|
||||
};
|
||||
type ContextMiddlewareMarker = typeof contextMiddlewareMarker;
|
||||
|
||||
interface ContextMiddlewareResultBase {
|
||||
/**
|
||||
* All middlewares should pass through their `next()`'s output.
|
||||
* Requiring this marker makes sure that can't be forgotten at compile-time.
|
||||
*/
|
||||
readonly marker: ContextMiddlewareMarker;
|
||||
}
|
||||
|
||||
interface ContextMiddlewareOKResult<_TContextOverride> extends ContextMiddlewareResultBase {
|
||||
ok: true;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface ContextMiddlewareErrorResult<_TContextOverride> extends ContextMiddlewareResultBase {
|
||||
ok: false;
|
||||
error: Error; // should be our error
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type ContextMiddlewareResult<_TContextOverride> =
|
||||
| ContextMiddlewareErrorResult<_TContextOverride>
|
||||
| ContextMiddlewareOKResult<_TContextOverride>;
|
||||
|
||||
export type ContextMiddlewareFunction<TContext, TContextOverridesIn, $ContextOverridesOut> = {
|
||||
(opts: {
|
||||
ctx: Simplify<Overwrite<TContext, TContextOverridesIn>>;
|
||||
meta: RunMetadata;
|
||||
next: {
|
||||
(): Promise<ContextMiddlewareResult<TContextOverridesIn>>;
|
||||
<$ContextOverride>(ctx: $ContextOverride): Promise<ContextMiddlewareResult<$ContextOverride>>;
|
||||
};
|
||||
}): Promise<ContextMiddlewareResult<$ContextOverridesOut>>;
|
||||
};
|
||||
|
||||
export const unsetMarker = Symbol("unsetMarker");
|
||||
export type UnsetMarker = typeof unsetMarker;
|
||||
|
||||
export interface ContextBuilder<TContext extends object, TContextOverrides> {
|
||||
use<$ContextOverridesOut>(
|
||||
fn: ContextMiddlewareFunction<TContext, TContextOverrides, $ContextOverridesOut>
|
||||
): ContextBuilder<TContext, Overwrite<TContextOverrides, $ContextOverridesOut>>;
|
||||
}
|
||||
|
||||
export type AnyContextBuilder = ContextBuilder<any, any>;
|
||||
|
||||
export function createContext<TContext extends object>(
|
||||
initialContext?: TContext
|
||||
): ContextBuilder<TContext, UnsetMarker> {
|
||||
const builder: AnyContextBuilder = {
|
||||
use(middlewareFn) {
|
||||
return {} as AnyContextBuilder;
|
||||
},
|
||||
};
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
const contextBuilder = createContext({ foo: "bar" });
|
||||
const context = contextBuilder
|
||||
.use((opts) => {
|
||||
return opts.next({
|
||||
baz: "whatever",
|
||||
});
|
||||
})
|
||||
.use((opts) => {
|
||||
return opts.next({
|
||||
db: {
|
||||
find: async (id: string) => {
|
||||
return "hello";
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
type ctx = inferContext<typeof context>;
|
||||
|
||||
const contextBuilder2 = createContext();
|
||||
|
||||
type ctx2 = inferContext<typeof contextBuilder2>;
|
||||
|
||||
const contextBuilder3 = createContext({ bar: "baz" });
|
||||
|
||||
type ctx3 = inferContext<typeof contextBuilder3>;
|
||||
|
||||
const contextBuilder4 = createContext().use((opts) => {
|
||||
return opts.next({
|
||||
hello: "world",
|
||||
});
|
||||
});
|
||||
|
||||
type ctx4 = inferContext<typeof contextBuilder4>;
|
||||
|
||||
export type TaskOptions<
|
||||
TOutput,
|
||||
TContext extends AnyContextBuilder,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
> = {
|
||||
/** An id for your task. This must be unique inside your project and not change between versions. */
|
||||
id: TIdentifier;
|
||||
|
||||
schema?: TParser;
|
||||
|
||||
context?: TContext;
|
||||
|
||||
/** This gets called when a task is triggered. It's where you put the code you want to execute.
|
||||
*
|
||||
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
|
||||
* @param params - Metadata about the run.
|
||||
*/
|
||||
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
|
||||
};
|
||||
|
||||
export interface Task<
|
||||
TOutput,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
> {
|
||||
/**
|
||||
* The id of the task.
|
||||
*/
|
||||
id: TIdentifier;
|
||||
/**
|
||||
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
|
||||
* @param payload
|
||||
* @param options
|
||||
* @returns TriggerResult
|
||||
* - `id` - The id of the triggered task run.
|
||||
*/
|
||||
trigger: (
|
||||
payload: Simplify<inferParserIn<TParser, any>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TriggerResult>;
|
||||
|
||||
/**
|
||||
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
|
||||
* @param payload
|
||||
* @param options - Options for the task run
|
||||
* @returns TaskRunResult
|
||||
* @example
|
||||
* ```
|
||||
* const result = await task.triggerAndWait({ foo: "bar" });
|
||||
*
|
||||
* if (result.ok) {
|
||||
* console.log(result.output);
|
||||
* } else {
|
||||
* console.error(result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
triggerAndWait: (
|
||||
payload: Simplify<inferParserIn<TParser, any>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TaskRunResult<TOutput>>;
|
||||
}
|
||||
|
||||
export type AnyTask = Task<any, string, any>;
|
||||
|
||||
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
|
||||
? inferParser<TParser>["in"]
|
||||
: TDefault;
|
||||
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
|
||||
? inferParser<TParser>["out"]
|
||||
: TDefault;
|
||||
|
||||
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
|
||||
? inferParserIn<TParser>
|
||||
: never;
|
||||
|
||||
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
|
||||
? inferParserOut<TParser>
|
||||
: never;
|
||||
|
||||
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
|
||||
? TOutput
|
||||
: never;
|
||||
|
||||
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
|
||||
? TIdentifier
|
||||
: never;
|
||||
|
||||
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
|
||||
infer TOutput,
|
||||
infer TIdentifier,
|
||||
infer TParser
|
||||
>
|
||||
? {
|
||||
id: TIdentifier;
|
||||
payloadIn: inferParserIn<TParser>;
|
||||
payloadOut: inferParserOut<TParser>;
|
||||
output: TOutput;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type TriggerTaskOptions = {
|
||||
idempotencyKey?: string;
|
||||
maxAttempts?: number;
|
||||
startAt?: Date;
|
||||
startAfter?: number;
|
||||
concurrencyKey?: string;
|
||||
};
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
||||
export function task<
|
||||
TOutput,
|
||||
TContext extends AnyContextBuilder,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
>(
|
||||
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
|
||||
): Task<TOutput, TIdentifier, TParser> {
|
||||
return createTask(options);
|
||||
}
|
||||
|
||||
export function createTask<
|
||||
TOutput,
|
||||
TContext extends AnyContextBuilder,
|
||||
TIndentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
>(
|
||||
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
|
||||
): Task<TOutput, TIndentifier, TParser> {
|
||||
const task: Task<TOutput, TIndentifier, TParser> = {
|
||||
id: params.id,
|
||||
trigger: async (payload, options) => {
|
||||
return {
|
||||
id: "run_1234",
|
||||
};
|
||||
},
|
||||
triggerAndWait: async (payload, options) => {
|
||||
const output = await params.run({
|
||||
meta: { run: "run_1234" },
|
||||
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
|
||||
ctx: {} as inferContext<TContext>,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: "run_1234",
|
||||
output,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export interface TaskLibraryRecord {
|
||||
[key: string]: AnyTask | TaskLibraryRecord;
|
||||
}
|
||||
|
||||
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
|
||||
_def: { record: TRecord };
|
||||
}
|
||||
|
||||
export type AnyTaskLibrary = TaskLibrary<any>;
|
||||
|
||||
export type CreateTaskLibraryOptions = {
|
||||
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
|
||||
};
|
||||
|
||||
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
|
||||
{
|
||||
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
|
||||
? $Value extends AnyTask
|
||||
? $Value
|
||||
: $Value extends TaskLibrary<infer TRecord>
|
||||
? TRecord
|
||||
: $Value extends CreateTaskLibraryOptions
|
||||
? DecorateCreateTaskLibraryOptions<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
|
||||
input: TInput
|
||||
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
|
||||
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
|
||||
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
|
||||
// TODO: reserved words
|
||||
|
||||
return {
|
||||
_def: {
|
||||
record: input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ======== client side
|
||||
type DecorateTask<TTask extends AnyTask> = {
|
||||
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
type DecoratedTaskLibraryRecord<
|
||||
TTaskLibrary extends AnyTaskLibrary,
|
||||
TRecord extends TaskLibraryRecord,
|
||||
> = {
|
||||
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
|
||||
? $Value extends TaskLibraryRecord
|
||||
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
|
||||
: $Value extends AnyTask
|
||||
? DecorateTask<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
|
||||
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
|
||||
|
||||
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
|
||||
lib: inferTaskLibraryClient<TTaskLibrary>;
|
||||
runs: {
|
||||
retrieve: (id: string) => Promise<{ status: boolean }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateTriggerClientOptions = {
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
|
||||
options?: CreateTriggerClientOptions
|
||||
): CreateTriggerClient<TTaskLibrary> {
|
||||
return {} as CreateTriggerClient<TTaskLibrary>;
|
||||
}
|
||||
|
||||
// trigger/my-tasks.ts
|
||||
const taskOne = task({
|
||||
id: "task-1",
|
||||
run: async () => {
|
||||
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
|
||||
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
|
||||
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const taskTwo = task({
|
||||
id: "task-2",
|
||||
async run(params) {
|
||||
return {
|
||||
hello: "world",
|
||||
payload: params.payload.other,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskOne = task({
|
||||
id: "user/task-1",
|
||||
context: contextBuilder4,
|
||||
run: async (params) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskTwo = task({
|
||||
id: "user/task-2",
|
||||
context: contextBuilder3,
|
||||
run: async (params) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const zodTaskOne = task({
|
||||
id: "zod/task-1",
|
||||
context: contextBuilder,
|
||||
schema: z.object({ foo: z.string() }),
|
||||
run: async (params) => {},
|
||||
});
|
||||
|
||||
const zodTaskTwo = task({
|
||||
id: "zod/task-2",
|
||||
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
|
||||
context: contextBuilder2,
|
||||
run: async (params) => {
|
||||
console.log(params.payload.foo, params.meta.run);
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskOne = task({
|
||||
id: "valibot/task-1",
|
||||
schema: wrap(
|
||||
v.object({
|
||||
foo: v.string(),
|
||||
})
|
||||
),
|
||||
run: async (params) => {
|
||||
await zodTaskOne.trigger({ foo: "bar" });
|
||||
await zodTaskTwo.trigger({ foo: "bar" });
|
||||
|
||||
await valibotTaskTwo.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskTwo = task({
|
||||
id: "valibot/task-2",
|
||||
schema: wrap(
|
||||
v.object({
|
||||
foo: v.string(),
|
||||
isAdmin: v.optional(v.boolean(), true),
|
||||
})
|
||||
),
|
||||
run: async (params) => {
|
||||
await valibotTaskOne.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
// in trigger/lib.ts
|
||||
const myTaskLibrary = taskLibrary({
|
||||
myTasks: { taskOne, taskTwo },
|
||||
});
|
||||
|
||||
const userTaskLibrary = taskLibrary({
|
||||
userTaskOne,
|
||||
userTaskTwo,
|
||||
});
|
||||
|
||||
const zodTaskLibrary = taskLibrary({
|
||||
zodTaskOne,
|
||||
zodTaskTwo,
|
||||
});
|
||||
|
||||
const valibotTaskLibrary = taskLibrary({
|
||||
valibotTaskOne,
|
||||
valibotTaskTwo,
|
||||
});
|
||||
|
||||
export const library = taskLibrary({
|
||||
foo: myTaskLibrary,
|
||||
bar: userTaskLibrary,
|
||||
zod: zodTaskLibrary,
|
||||
valibot: valibotTaskLibrary,
|
||||
});
|
||||
|
||||
// Export the library type
|
||||
export type Library = typeof library;
|
||||
|
||||
// Now on the client
|
||||
const client = createTriggerClient<Library>({
|
||||
secretKey: "tr_dev_1234",
|
||||
});
|
||||
|
||||
client.runs.retrieve("run_12343"); // Call regular API client calls
|
||||
|
||||
// Tasks are now available under lib
|
||||
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
|
||||
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", {
|
||||
userId: "user_123",
|
||||
isAdmin: true,
|
||||
});
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", {
|
||||
userId: "user_123",
|
||||
isAdmin: false,
|
||||
});
|
||||
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", {
|
||||
foo: "bar",
|
||||
isAdmin: true,
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
|
||||
export type ParserZodEsque<TInput, TParsedInput> = {
|
||||
_input: TInput;
|
||||
_output: TParsedInput;
|
||||
};
|
||||
|
||||
export type ParserMyZodEsque<TInput> = {
|
||||
parse: (input: any) => TInput;
|
||||
};
|
||||
|
||||
export type ParserSuperstructEsque<TInput> = {
|
||||
create: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
|
||||
|
||||
export type ParserYupEsque<TInput> = {
|
||||
validateSync: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserScaleEsque<TInput> = {
|
||||
assert(value: unknown): asserts value is TInput;
|
||||
};
|
||||
|
||||
export type ParserWithoutInput<TInput> =
|
||||
| ParserCustomValidatorEsque<TInput>
|
||||
| ParserMyZodEsque<TInput>
|
||||
| ParserScaleEsque<TInput>
|
||||
| ParserSuperstructEsque<TInput>
|
||||
| ParserYupEsque<TInput>;
|
||||
|
||||
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
|
||||
|
||||
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
|
||||
|
||||
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
|
||||
infer $TIn,
|
||||
infer $TOut
|
||||
>
|
||||
? {
|
||||
in: $TIn;
|
||||
out: $TOut;
|
||||
}
|
||||
: TParser extends ParserWithoutInput<infer $InOut>
|
||||
? {
|
||||
in: $InOut;
|
||||
out: $InOut;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
|
||||
|
||||
export type TriggerResult = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TaskRunResult<TOutput = any> =
|
||||
| {
|
||||
ok: true;
|
||||
id: string;
|
||||
output: TOutput;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
id: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type RunMetadata = {
|
||||
run: string;
|
||||
};
|
||||
|
||||
export type RunFnParams<TPayload, TContext extends object> = {
|
||||
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
|
||||
meta: RunMetadata;
|
||||
|
||||
/** Context added by task middleware */
|
||||
ctx: TContext;
|
||||
|
||||
payload: TPayload;
|
||||
};
|
||||
|
||||
export type TaskOptions<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
> = {
|
||||
/** An id for your task. This must be unique inside your project and not change between versions. */
|
||||
id: TIdentifier;
|
||||
|
||||
schema?: TParser;
|
||||
|
||||
/** This gets called when a task is triggered. It's where you put the code you want to execute.
|
||||
*
|
||||
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
|
||||
* @param params - Metadata about the run.
|
||||
*/
|
||||
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
|
||||
};
|
||||
|
||||
export interface Task<
|
||||
TOutput,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
> {
|
||||
/**
|
||||
* The id of the task.
|
||||
*/
|
||||
id: TIdentifier;
|
||||
/**
|
||||
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
|
||||
* @param payload
|
||||
* @param options
|
||||
* @returns TriggerResult
|
||||
* - `id` - The id of the triggered task run.
|
||||
*/
|
||||
trigger: (
|
||||
payload: Simplify<inferParserIn<TParser, any>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TriggerResult>;
|
||||
|
||||
/**
|
||||
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
|
||||
* @param payload
|
||||
* @param options - Options for the task run
|
||||
* @returns TaskRunResult
|
||||
* @example
|
||||
* ```
|
||||
* const result = await task.triggerAndWait({ foo: "bar" });
|
||||
*
|
||||
* if (result.ok) {
|
||||
* console.log(result.output);
|
||||
* } else {
|
||||
* console.error(result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
triggerAndWait: (
|
||||
payload: Simplify<inferParserIn<TParser, any>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TaskRunResult<TOutput>>;
|
||||
}
|
||||
|
||||
export type AnyTask = Task<any, string, any>;
|
||||
|
||||
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
|
||||
? inferParser<TParser>["in"]
|
||||
: TDefault;
|
||||
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
|
||||
? inferParser<TParser>["out"]
|
||||
: TDefault;
|
||||
|
||||
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
|
||||
? inferParserIn<TParser>
|
||||
: never;
|
||||
|
||||
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
|
||||
? inferParserOut<TParser>
|
||||
: never;
|
||||
|
||||
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
|
||||
? TOutput
|
||||
: never;
|
||||
|
||||
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
|
||||
? TIdentifier
|
||||
: never;
|
||||
|
||||
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
|
||||
infer TOutput,
|
||||
infer TIdentifier,
|
||||
infer TParser
|
||||
>
|
||||
? {
|
||||
id: TIdentifier;
|
||||
payloadIn: inferParserIn<TParser>;
|
||||
payloadOut: inferParserOut<TParser>;
|
||||
output: TOutput;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type TriggerTaskOptions = {
|
||||
idempotencyKey?: string;
|
||||
maxAttempts?: number;
|
||||
startAt?: Date;
|
||||
startAfter?: number;
|
||||
concurrencyKey?: string;
|
||||
};
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
||||
export function task<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
>(
|
||||
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
|
||||
): Task<TOutput, TIdentifier, TParser> {
|
||||
return createTask(options);
|
||||
}
|
||||
|
||||
export function createTask<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIndentifier extends string,
|
||||
TParser extends Parser | undefined = undefined,
|
||||
>(
|
||||
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
|
||||
): Task<TOutput, TIndentifier, TParser> {
|
||||
const task: Task<TOutput, TIndentifier, TParser> = {
|
||||
id: params.id,
|
||||
trigger: async (payload, options) => {
|
||||
return {
|
||||
id: "run_1234",
|
||||
};
|
||||
},
|
||||
triggerAndWait: async (payload, options) => {
|
||||
const output = await params.run({
|
||||
meta: { run: "run_1234" },
|
||||
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
|
||||
ctx: {} as TContext,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: "run_1234",
|
||||
output,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export interface TaskLibraryRecord {
|
||||
[key: string]: AnyTask | TaskLibraryRecord;
|
||||
}
|
||||
|
||||
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
|
||||
_def: { record: TRecord };
|
||||
}
|
||||
|
||||
export type AnyTaskLibrary = TaskLibrary<any>;
|
||||
|
||||
export type CreateTaskLibraryOptions = {
|
||||
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
|
||||
};
|
||||
|
||||
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
|
||||
{
|
||||
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
|
||||
? $Value extends AnyTask
|
||||
? $Value
|
||||
: $Value extends TaskLibrary<infer TRecord>
|
||||
? TRecord
|
||||
: $Value extends CreateTaskLibraryOptions
|
||||
? DecorateCreateTaskLibraryOptions<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
|
||||
input: TInput
|
||||
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
|
||||
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
|
||||
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
|
||||
// TODO: reserved words
|
||||
|
||||
return {
|
||||
_def: {
|
||||
record: input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ======== client side
|
||||
type DecorateTask<TTask extends AnyTask> = {
|
||||
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
type DecoratedTaskLibraryRecord<
|
||||
TTaskLibrary extends AnyTaskLibrary,
|
||||
TRecord extends TaskLibraryRecord,
|
||||
> = {
|
||||
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
|
||||
? $Value extends TaskLibraryRecord
|
||||
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
|
||||
: $Value extends AnyTask
|
||||
? DecorateTask<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
|
||||
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
|
||||
|
||||
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
|
||||
lib: inferTaskLibraryClient<TTaskLibrary>;
|
||||
runs: {
|
||||
retrieve: (id: string) => Promise<{ status: boolean }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateTriggerClientOptions = {
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
|
||||
options?: CreateTriggerClientOptions
|
||||
): CreateTriggerClient<TTaskLibrary> {
|
||||
return {} as CreateTriggerClient<TTaskLibrary>;
|
||||
}
|
||||
|
||||
// trigger/my-tasks.ts
|
||||
const taskOne = task({
|
||||
id: "task-1",
|
||||
run: async () => {
|
||||
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
|
||||
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
|
||||
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const taskTwo = task({
|
||||
id: "task-2",
|
||||
async run(params) {
|
||||
return {
|
||||
hello: "world",
|
||||
payload: params.payload.other,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskOne = task({
|
||||
id: "user/task-1",
|
||||
run: async (params) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskTwo = task({
|
||||
id: "user/task-2",
|
||||
run: async (params) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const zodTaskOne = task({
|
||||
id: "zod/task-1",
|
||||
schema: z.object({ foo: z.string() }),
|
||||
run: async (params) => {},
|
||||
});
|
||||
|
||||
const zodTaskTwo = task({
|
||||
id: "zod/task-2",
|
||||
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
|
||||
run: async (params) => {
|
||||
console.log(params.payload.foo, params.meta.run);
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskOne = task({
|
||||
id: "valibot/task-1",
|
||||
schema: wrap(
|
||||
v.object({
|
||||
foo: v.string(),
|
||||
})
|
||||
),
|
||||
run: async (params) => {
|
||||
await zodTaskOne.trigger({ foo: "bar" });
|
||||
await zodTaskTwo.trigger({ foo: "bar" });
|
||||
|
||||
await valibotTaskTwo.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskTwo = task({
|
||||
id: "valibot/task-2",
|
||||
schema: wrap(
|
||||
v.object({
|
||||
foo: v.string(),
|
||||
isAdmin: v.optional(v.boolean(), true),
|
||||
})
|
||||
),
|
||||
run: async (params) => {
|
||||
await valibotTaskOne.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
// in trigger/lib.ts
|
||||
const myTaskLibrary = taskLibrary({
|
||||
myTasks: { taskOne, taskTwo },
|
||||
});
|
||||
|
||||
const userTaskLibrary = taskLibrary({
|
||||
userTaskOne,
|
||||
userTaskTwo,
|
||||
});
|
||||
|
||||
const zodTaskLibrary = taskLibrary({
|
||||
zodTaskOne,
|
||||
zodTaskTwo,
|
||||
});
|
||||
|
||||
const valibotTaskLibrary = taskLibrary({
|
||||
valibotTaskOne,
|
||||
valibotTaskTwo,
|
||||
});
|
||||
|
||||
export const library = taskLibrary({
|
||||
foo: myTaskLibrary,
|
||||
bar: userTaskLibrary,
|
||||
zod: zodTaskLibrary,
|
||||
valibot: valibotTaskLibrary,
|
||||
});
|
||||
|
||||
// Export the library type
|
||||
export type Library = typeof library;
|
||||
|
||||
// Now on the client
|
||||
const client = createTriggerClient<Library>({
|
||||
secretKey: "tr_dev_1234",
|
||||
});
|
||||
|
||||
client.runs.retrieve("run_12343"); // Call regular API client calls
|
||||
|
||||
// Tasks are now available under lib
|
||||
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
|
||||
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: true });
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: false });
|
||||
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar", isAdmin: true });
|
||||
@@ -0,0 +1,499 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
|
||||
export type ParserZodEsque<TInput, TParsedInput> = {
|
||||
_input: TInput;
|
||||
_output: TParsedInput;
|
||||
};
|
||||
|
||||
export type ParserMyZodEsque<TInput> = {
|
||||
parse: (input: any) => TInput;
|
||||
};
|
||||
|
||||
export type ParserSuperstructEsque<TInput> = {
|
||||
create: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
|
||||
|
||||
export type ParserYupEsque<TInput> = {
|
||||
validateSync: (input: unknown) => TInput;
|
||||
};
|
||||
|
||||
export type ParserScaleEsque<TInput> = {
|
||||
assert(value: unknown): asserts value is TInput;
|
||||
};
|
||||
|
||||
export type ParserWithoutInput<TInput> =
|
||||
| ParserCustomValidatorEsque<TInput>
|
||||
| ParserMyZodEsque<TInput>
|
||||
| ParserScaleEsque<TInput>
|
||||
| ParserSuperstructEsque<TInput>
|
||||
| ParserYupEsque<TInput>;
|
||||
|
||||
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
|
||||
|
||||
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
|
||||
|
||||
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
|
||||
infer $TIn,
|
||||
infer $TOut
|
||||
>
|
||||
? {
|
||||
in: $TIn;
|
||||
out: $TOut;
|
||||
}
|
||||
: TParser extends ParserWithoutInput<infer $InOut>
|
||||
? {
|
||||
in: $InOut;
|
||||
out: $InOut;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
|
||||
|
||||
export type TriggerResult = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TaskRunResult<TOutput = any> =
|
||||
| {
|
||||
ok: true;
|
||||
id: string;
|
||||
output: TOutput;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
id: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type RunMetadata = {
|
||||
run: string;
|
||||
};
|
||||
|
||||
export type RunFnParams<TPayload, TContext extends object> = {
|
||||
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
|
||||
meta: RunMetadata;
|
||||
|
||||
/** Context added by task middleware */
|
||||
ctx: TContext;
|
||||
|
||||
payload: TPayload;
|
||||
};
|
||||
|
||||
export type TaskOptions<
|
||||
TPayloadIn,
|
||||
TPayloadOut,
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
> = {
|
||||
/** An id for your task. This must be unique inside your project and not change between versions. */
|
||||
id: TIdentifier;
|
||||
|
||||
schema?: Parser;
|
||||
|
||||
/** This gets called when a task is triggered. It's where you put the code you want to execute.
|
||||
*
|
||||
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
|
||||
* @param params - Metadata about the run.
|
||||
*/
|
||||
run: (
|
||||
params: Simplify<RunFnParams<inferTaskPayloadOut<TPayloadIn, TPayloadOut>, TContext>>
|
||||
) => Promise<TOutput>;
|
||||
};
|
||||
|
||||
export interface Task<TPayloadIn, TPayloadOut, TOutput, TIdentifier extends string> {
|
||||
/**
|
||||
* The id of the task.
|
||||
*/
|
||||
id: TIdentifier;
|
||||
/**
|
||||
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
|
||||
* @param payload
|
||||
* @param options
|
||||
* @returns TriggerResult
|
||||
* - `id` - The id of the triggered task run.
|
||||
*/
|
||||
trigger: (
|
||||
payload: Simplify<inferTaskPayloadIn<TPayloadIn, TPayloadOut>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TriggerResult>;
|
||||
|
||||
/**
|
||||
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
|
||||
* @param payload
|
||||
* @param options - Options for the task run
|
||||
* @returns TaskRunResult
|
||||
* @example
|
||||
* ```
|
||||
* const result = await task.triggerAndWait({ foo: "bar" });
|
||||
*
|
||||
* if (result.ok) {
|
||||
* console.log(result.output);
|
||||
* } else {
|
||||
* console.error(result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
triggerAndWait: (
|
||||
payload: Simplify<inferTaskPayloadIn<TPayloadIn, TPayloadOut>>,
|
||||
options?: TriggerTaskOptions
|
||||
) => Promise<TaskRunResult<TOutput>>;
|
||||
}
|
||||
|
||||
export type AnyTask = Task<any, any, any, string>;
|
||||
|
||||
type IsUnknown<T> = unknown extends T ? (T extends unknown ? true : false) : false;
|
||||
type NonUnknown<T> = IsUnknown<T> extends true ? never : T;
|
||||
|
||||
export type inferTaskPayloadIn<TPayloadIn, TPayloadOut> = NonUnknown<TPayloadIn> extends never
|
||||
? TPayloadOut
|
||||
: TPayloadIn;
|
||||
export type inferTaskPayloadOut<TPayloadIn, TPayloadOut> = NonUnknown<TPayloadOut> extends never
|
||||
? TPayloadIn
|
||||
: TPayloadOut;
|
||||
|
||||
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<
|
||||
infer TPayloadIn,
|
||||
infer TPayloadOut,
|
||||
any,
|
||||
string
|
||||
>
|
||||
? inferTaskPayloadIn<TPayloadIn, TPayloadOut>
|
||||
: never;
|
||||
|
||||
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<
|
||||
infer TPayloadIn,
|
||||
infer TPayloadOut,
|
||||
any,
|
||||
string
|
||||
>
|
||||
? inferTaskPayloadOut<TPayloadIn, TPayloadOut>
|
||||
: never;
|
||||
|
||||
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<any, any, infer TOutput, string>
|
||||
? TOutput
|
||||
: never;
|
||||
|
||||
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<
|
||||
any,
|
||||
any,
|
||||
any,
|
||||
infer TIdentifier
|
||||
>
|
||||
? TIdentifier
|
||||
: never;
|
||||
|
||||
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
|
||||
infer TPayloadIn,
|
||||
infer TPayloadOut,
|
||||
infer TOutput,
|
||||
infer TIdentifier
|
||||
>
|
||||
? {
|
||||
id: TIdentifier;
|
||||
payloadIn: TPayloadIn;
|
||||
payloadOut: TPayloadOut;
|
||||
output: TOutput;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type TriggerTaskOptions = {
|
||||
idempotencyKey?: string;
|
||||
maxAttempts?: number;
|
||||
startAt?: Date;
|
||||
startAfter?: number;
|
||||
concurrencyKey?: string;
|
||||
};
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
||||
export function task<
|
||||
TPayloadIn,
|
||||
TPayloadOut,
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
>(
|
||||
options: TaskOptions<TPayloadIn, TPayloadOut, TOutput, TContext, TIdentifier>
|
||||
): Task<TPayloadIn, TPayloadOut, TOutput, TIdentifier> {
|
||||
return createTask<TPayloadIn, TPayloadOut, TOutput, TContext, TIdentifier>(options);
|
||||
}
|
||||
|
||||
export function createTask<
|
||||
TPayloadIn,
|
||||
TPayloadOut,
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIndentifier extends string,
|
||||
>(
|
||||
params: TaskOptions<TPayloadIn, TPayloadOut, TOutput, TContext, TIndentifier>
|
||||
): Task<TPayloadIn, TPayloadOut, TOutput, TIndentifier> {
|
||||
const task: Task<TPayloadIn, TPayloadOut, TOutput, TIndentifier> = {
|
||||
id: params.id,
|
||||
trigger: async (payload, options) => {
|
||||
return {
|
||||
id: "run_1234",
|
||||
};
|
||||
},
|
||||
triggerAndWait: async (payload, options) => {
|
||||
const output = await params.run({
|
||||
meta: { run: "run_1234" },
|
||||
payload: payload as unknown as inferTaskPayloadOut<TPayloadIn, TPayloadOut>,
|
||||
ctx: {} as TContext,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: "run_1234",
|
||||
output,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export type ZodTaskOptions<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TSchema extends z.ZodTypeAny = z.ZodTypeAny,
|
||||
> = {
|
||||
schema: TSchema;
|
||||
} & TaskOptions<z.input<TSchema>, z.output<TSchema>, TOutput, TContext, TIdentifier>;
|
||||
|
||||
export function zodTask<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TSchema extends z.ZodTypeAny = z.ZodTypeAny,
|
||||
>(
|
||||
options: ZodTaskOptions<TOutput, TContext, TIdentifier, TSchema>
|
||||
): Task<z.input<TSchema>, z.output<TSchema>, TOutput, TIdentifier> {
|
||||
return createTask<z.input<TSchema>, z.output<TSchema>, TOutput, TContext, TIdentifier>(options);
|
||||
}
|
||||
|
||||
export type ValibotTaskOptions<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TSchema extends v.BaseSchema = v.AnySchema,
|
||||
> = {
|
||||
schema: TSchema;
|
||||
} & TaskOptions<v.Input<TSchema>, v.Output<TSchema>, TOutput, TContext, TIdentifier>;
|
||||
|
||||
export function valibotTask<
|
||||
TOutput,
|
||||
TContext extends object,
|
||||
TIdentifier extends string,
|
||||
TSchema extends v.BaseSchema = v.AnySchema,
|
||||
>(
|
||||
options: ValibotTaskOptions<TOutput, TContext, TIdentifier, TSchema>
|
||||
): Task<v.Input<TSchema>, v.Output<TSchema>, TOutput, TIdentifier> {
|
||||
return createTask<v.Input<TSchema>, v.Output<TSchema>, TOutput, TContext, TIdentifier>(options);
|
||||
}
|
||||
|
||||
export interface TaskLibraryRecord {
|
||||
[key: string]: AnyTask | TaskLibraryRecord;
|
||||
}
|
||||
|
||||
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
|
||||
_def: { record: TRecord };
|
||||
}
|
||||
|
||||
export type AnyTaskLibrary = TaskLibrary<any>;
|
||||
|
||||
export type CreateTaskLibraryOptions = {
|
||||
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
|
||||
};
|
||||
|
||||
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
|
||||
{
|
||||
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
|
||||
? $Value extends AnyTask
|
||||
? $Value
|
||||
: $Value extends TaskLibrary<infer TRecord>
|
||||
? TRecord
|
||||
: $Value extends CreateTaskLibraryOptions
|
||||
? DecorateCreateTaskLibraryOptions<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
|
||||
input: TInput
|
||||
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
|
||||
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
|
||||
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
|
||||
// TODO: reserved words
|
||||
|
||||
return {
|
||||
_def: {
|
||||
record: input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ======== client side
|
||||
type DecorateTask<TTask extends AnyTask> = {
|
||||
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
type DecoratedTaskLibraryRecord<
|
||||
TTaskLibrary extends AnyTaskLibrary,
|
||||
TRecord extends TaskLibraryRecord,
|
||||
> = {
|
||||
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
|
||||
? $Value extends TaskLibraryRecord
|
||||
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
|
||||
: $Value extends AnyTask
|
||||
? DecorateTask<$Value>
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
|
||||
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
|
||||
|
||||
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
|
||||
lib: inferTaskLibraryClient<TTaskLibrary>;
|
||||
runs: {
|
||||
retrieve: (id: string) => Promise<{ status: boolean }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateTriggerClientOptions = {
|
||||
secretKey?: string;
|
||||
};
|
||||
|
||||
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
|
||||
options?: CreateTriggerClientOptions
|
||||
): CreateTriggerClient<TTaskLibrary> {
|
||||
return {} as CreateTriggerClient<TTaskLibrary>;
|
||||
}
|
||||
|
||||
// trigger/my-tasks.ts
|
||||
const taskOne = task({
|
||||
id: "task-1",
|
||||
run: async () => {
|
||||
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
|
||||
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
|
||||
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const taskTwo = task({
|
||||
id: "task-2",
|
||||
async run(params) {
|
||||
return {
|
||||
hello: "world",
|
||||
payload: params.payload,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskOne = task({
|
||||
id: "user/task-1",
|
||||
run: async (params: { payload: { userId: string } }) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const userTaskTwo = task({
|
||||
id: "user/task-2",
|
||||
run: async (params: { payload: { userId: string; isAdmin: boolean } }) => {
|
||||
return "foo-bar";
|
||||
},
|
||||
});
|
||||
|
||||
const zodTaskOne = task({
|
||||
id: "zod/task-1",
|
||||
schema: z.object({ foo: z.string() }),
|
||||
run: async (params) => {},
|
||||
});
|
||||
|
||||
const zodTaskTwo = task({
|
||||
id: "zod/task-2",
|
||||
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
|
||||
run: async (params) => {
|
||||
console.log(params.payload.foo, params.meta.run);
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskOne = task({
|
||||
id: "valibot/task-1",
|
||||
schema: v.object({
|
||||
foo: v.string(),
|
||||
}),
|
||||
run: async (params) => {
|
||||
await zodTaskOne.trigger({ foo: "bar" });
|
||||
await zodTaskTwo.trigger({ foo: "bar" });
|
||||
|
||||
await valibotTaskTwo.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
const valibotTaskTwo = task({
|
||||
id: "valibot/task-2",
|
||||
schema: v.object({
|
||||
foo: v.string(),
|
||||
isAdmin: v.optional(v.boolean(), true),
|
||||
}),
|
||||
run: async (params) => {
|
||||
await valibotTaskOne.trigger({ foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
// in trigger/lib.ts
|
||||
const myTaskLibrary = taskLibrary({
|
||||
myTasks: { taskOne, taskTwo },
|
||||
});
|
||||
|
||||
const userTaskLibrary = taskLibrary({
|
||||
userTaskOne,
|
||||
userTaskTwo,
|
||||
});
|
||||
|
||||
const zodTaskLibrary = taskLibrary({
|
||||
zodTaskOne,
|
||||
zodTaskTwo,
|
||||
});
|
||||
|
||||
const valibotTaskLibrary = taskLibrary({
|
||||
valibotTaskOne,
|
||||
valibotTaskTwo,
|
||||
});
|
||||
|
||||
export const library = taskLibrary({
|
||||
foo: myTaskLibrary,
|
||||
bar: userTaskLibrary,
|
||||
zod: zodTaskLibrary,
|
||||
valibot: valibotTaskLibrary,
|
||||
});
|
||||
|
||||
// Export the library type
|
||||
export type Library = typeof library;
|
||||
|
||||
// Now on the client
|
||||
const client = createTriggerClient<Library>({
|
||||
secretKey: "tr_dev_1234",
|
||||
});
|
||||
|
||||
client.runs.retrieve("run_12343"); // Call regular API client calls
|
||||
|
||||
// Tasks are now available under lib
|
||||
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
|
||||
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: true });
|
||||
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: false });
|
||||
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
|
||||
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
|
||||
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar", isAdmin: false });
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/airtable
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/airtable",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for airtable",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"airtable": "^0.12.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/github
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/github",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official GitHub integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -30,8 +30,8 @@
|
||||
"@octokit/request-error": "^5.0.1",
|
||||
"@octokit/webhooks": "^12.0.10",
|
||||
"octokit": "^3.1.2",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/linear
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/linear",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for @linear/sdk",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@linear/sdk": "^8.0.0",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/slack
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/openai",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official OpenAI integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -42,8 +42,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "^4.16.1",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29"
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/plain
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/plain",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official Plain.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"@team-plain/typescript-sdk": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/replicate
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/replicate",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for replicate",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"replicate": "^0.18.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/resend
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/resend",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official Resend.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"resend": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/sendgrid
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/sendgrid",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for @sendgrid/mail",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sendgrid/mail": "^7.7.0",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29"
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.8.0"
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/shopify
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/shopify",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for @shopify/shopify-api",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify-api": "^8.0.2",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @trigger.dev/slack
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/slack",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official Slack integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^6.8.1",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/stripe
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/stripe",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for stripe",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"stripe": "^12.14.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/supabase
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/supabase",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "Trigger.dev integration for @supabase/supabase-js",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.26.0",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"supabase-management-js": "^1.0.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @trigger.dev/typeform
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.31
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@3.0.0-beta.30
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/typeform",
|
||||
"version": "3.0.0-beta.29",
|
||||
"version": "3.0.0-beta.31",
|
||||
"description": "The official Typeform integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.29",
|
||||
"@trigger.dev/integration-kit": "workspace:^3.0.0-beta.31",
|
||||
"@trigger.dev/sdk": "workspace:^3.0.0-beta.31",
|
||||
"@typeform/api-client": "^1.8.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @trigger.dev/astro
|
||||
|
||||
## 3.0.0-beta.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@3.0.0-beta.31
|
||||
|
||||
## 3.0.0-beta.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@3.0.0-beta.30
|
||||
|
||||
## 3.0.0-beta.29
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user