Merge branch 'main' into worker-upgrade
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Auto-yield run execution to help prevent duplicate task executions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/cli": patch
|
||||
---
|
||||
|
||||
add ability to use custom tunnel in dev command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/openai": patch
|
||||
---
|
||||
|
||||
Add ability to set baseURL to use different APIs (like Perplexity)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Updated icon documentation in runTasks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/cli": patch
|
||||
---
|
||||
|
||||
Added Next.js `maxDuration` commented out to the api/trigger file using CLI init
|
||||
@@ -19,7 +19,7 @@
|
||||
All of October we're participating in Hacktoberfest and invite you to join us! We have a bunch of issues labeled `🎃 Hacktoberfest` that are ready for you to work on which will count towards Hacktoberfest. We are also running our own game, earn 💎 points to win swag!
|
||||
|
||||
- Check out our [Hacktoberfest landing page](https://trigger.dev/hacktoberfest) for how to participate and win swag.
|
||||
- Contribute to either our [/trigger.dev](https://github.com/triggerdotdev/trigger.dev/labels/%F0%9F%8E%83%20hacktoberfest) or [/jobs-showcase](https://github.com/triggerdotdev/jobs-showcase/labels/%F0%9F%8E%83%20hacktoberfest) repositories and complete issues marked `🎃 Hacktoberfest` to be eligible for swag.
|
||||
- Contribute to either our [/trigger.dev](https://github.com/triggerdotdev/trigger.dev/labels/%F0%9F%8E%83%20hacktoberfest), [/api-reference](https://github.com/triggerdotdev/api-reference/issues?q=is%3Aopen+is%3Aissue+label%3A%F0%9F%8E%83hacktoberfest) or [/jobs-showcase](https://github.com/triggerdotdev/jobs-showcase/labels/%F0%9F%8E%83%20hacktoberfest) repositories and complete issues marked `🎃 Hacktoberfest` to be eligible for swag.
|
||||
- Join our [Discord](https://discord.gg/JtBAxBr2m3) and get involved in with the community.
|
||||
|
||||
_New to Hacktober? Check out the [Hacktoberfest website](https://hacktoberfest.digitalocean.com/) for more information._
|
||||
@@ -96,7 +96,7 @@ We provide an official trigger.dev docker image you can use to easily self-host
|
||||
|
||||
To setup and develop locally or contribute to the open source project, follow our [development guide](./CONTRIBUTING.md).
|
||||
|
||||
## 🙏 to our contributors
|
||||
## Meet the Amazing People Behind This Project 🚀
|
||||
|
||||
<a href="https://github.com/triggerdotdev/trigger.dev/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=triggerdotdev/trigger.dev" />
|
||||
|
||||
@@ -17,7 +17,6 @@ export function NavBar() {
|
||||
<LogoIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<Breadcrumb />
|
||||
<MobileDropdownMenu />
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<LinkButton to={docsRoot()} variant="secondary/small" LeadingIcon={BookOpenIcon}>
|
||||
@@ -46,83 +45,3 @@ export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDropdownMenu() {
|
||||
return (
|
||||
<Popover className="block sm:hidden">
|
||||
<Popover.Button
|
||||
className="bg-slate-70 relative z-10 flex h-8 w-8 items-center justify-center rounded border-none bg-opacity-50 focus-visible:border-none focus-visible:outline-none"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
{({ open }) => <MobileNavIcon open={open} />}
|
||||
</Popover.Button>
|
||||
<Transition.Root>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="duration-150 ease-in"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Popover.Overlay className="absolute left-0 top-0 h-full w-full origin-top bg-midnight-900/80" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="duration-150 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-100 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="absolute inset-x-6 top-0 mt-20 flex origin-top flex-col gap-4 rounded-md bg-slate-800 p-4 text-lg tracking-tight text-slate-900 shadow-xl ring-1 ring-slate-700"
|
||||
>
|
||||
{/* <PrimaryA
|
||||
href={docsRoot()}
|
||||
target="_blank"
|
||||
className="max-w-full"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="-ml-1 h-4 w-4" />
|
||||
Documentation
|
||||
</PrimaryA>
|
||||
<PrimaryButton
|
||||
data-attr="posthog-feedback-button"
|
||||
className="max-w-full"
|
||||
>
|
||||
<ChatBubbleLeftRightIcon className="-ml-1 h-4 w-4" />
|
||||
Send us feedback
|
||||
</PrimaryButton>
|
||||
<SecondaryLink to="/logout" className="max-w-full">
|
||||
Logout
|
||||
</SecondaryLink> */}
|
||||
</Popover.Panel>
|
||||
</Transition.Child>
|
||||
</Transition.Root>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 overflow-visible stroke-slate-300"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path
|
||||
d="M0 1H14M0 7H14M0 13H14"
|
||||
className={cn("origin-center transition", open && "scale-90 opacity-0")}
|
||||
/>
|
||||
<path
|
||||
d="M2 2L12 12M12 2L2 12"
|
||||
className={cn("origin-center transition", !open && "scale-90 opacity-0")}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ import { Spinner } from "./Spinner";
|
||||
import { SaplingIcon } from "~/assets/icons/SaplingIcon";
|
||||
import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon";
|
||||
import { OneTreeIcon } from "~/assets/icons/OneTreeIcon";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import tablerSpritePath from "./tabler-sprite.svg";
|
||||
|
||||
const icons = {
|
||||
account: (className: string) => <UserCircleIcon className={cn("text-slate-400", className)} />,
|
||||
@@ -215,7 +217,15 @@ export function NamedIcon({
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Icon ${name} not found`);
|
||||
if (tablerIcons.has("tabler-" + name)) {
|
||||
return <TablerIcon name={"tabler-" + name} className={className} />;
|
||||
} else if (name.startsWith("tabler-") && tablerIcons.has(name)) {
|
||||
return <TablerIcon name={name} className={className} />;
|
||||
}
|
||||
|
||||
if (name === "supabase-management") {
|
||||
return <NamedIcon name="supabase" className={className} />;
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
@@ -247,3 +257,11 @@ export function NamedIconInBox({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TablerIcon({ name, className }: { name: string; className?: string }) {
|
||||
return (
|
||||
<svg className={cn("stroke-[1.5]", className)}>
|
||||
<use xlinkHref={`${tablerSpritePath}#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { withDesign } from "storybook-addon-designs";
|
||||
import { NamedIcon, iconNames } from "../primitives/NamedIcon";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
|
||||
const meta: Meta<typeof NamedIcons> = {
|
||||
title: "Icons",
|
||||
@@ -25,17 +27,35 @@ export const Basic: Story = {
|
||||
|
||||
function NamedIcons() {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{iconNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6"} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Header1 spacing>Internal</Header1>
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{iconNames
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6"} />
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Header1 spacing>Tabler</Header1>
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
{Array.from(tablerIcons).map((iconName) => (
|
||||
<div key={iconName} className="flex items-center gap-2">
|
||||
<div>
|
||||
<NamedIcon name={iconName} className={"h-6 w-6 text-indigo-500"} />
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
<span className="text-xs text-dimmed">{iconName}</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,6 @@ export const MAX_CONCURRENT_RUNS_LIMIT = 20;
|
||||
export const PREPROCESS_RETRY_LIMIT = 2;
|
||||
export const EXECUTE_JOB_RETRY_LIMIT = 10;
|
||||
export const MAX_RUN_YIELDED_EXECUTIONS = 100;
|
||||
export const RUN_CHUNK_EXECUTION_BUFFER = 350;
|
||||
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
|
||||
export const RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
|
||||
|
||||
@@ -40,6 +40,7 @@ const EnvironmentSchema = z.object({
|
||||
EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
WORKER_ENABLED: z.string().default("true"),
|
||||
EXECUTION_WORKER_ENABLED: z.string().default("true"),
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000),
|
||||
FAIL_LOCKED_JOBS_FOR_MIGRATION: z.string().default("false"),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RouteMatch } from "@remix-run/react";
|
||||
import { MatchedProject, useOptionalProject } from "./useProject";
|
||||
import { useUser } from "./useUser";
|
||||
|
||||
export type ProjectJobEnvironment = MatchedProject["environments"][number];
|
||||
|
||||
@@ -11,10 +12,13 @@ export function useEnvironments(matches?: RouteMatch[]) {
|
||||
}
|
||||
|
||||
export function useDevEnvironment(matches?: RouteMatch[]) {
|
||||
const user = useUser();
|
||||
const environments = useEnvironments(matches);
|
||||
if (!environments) return;
|
||||
|
||||
return environments.find((environment) => environment.type === "DEVELOPMENT");
|
||||
return environments.find(
|
||||
(environment) => environment.type === "DEVELOPMENT" && environment.userId === user.id
|
||||
);
|
||||
}
|
||||
|
||||
export function useProdEnvironment(matches?: RouteMatch[]) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { prisma } from "~/db.server";
|
||||
import { Prettify } from "~/lib.es5";
|
||||
|
||||
@@ -18,3 +19,14 @@ export async function findEndpoint(id: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function detectResponseIsTimeout(response?: Response) {
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
|
||||
response.headers.get("x-vercel-error") === "FUNCTION_INVOCATION_TIMEOUT"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,6 @@ import { JobRun, JobRunExecution } from "@trigger.dev/database";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { executionWorker } from "~/services/worker.server";
|
||||
|
||||
export async function enqueueRunExecutionV1(
|
||||
execution: JobRunExecution,
|
||||
queueId: string,
|
||||
concurrency: number,
|
||||
tx: PrismaClientOrTransaction,
|
||||
runAt?: Date
|
||||
) {
|
||||
const job = await executionWorker.enqueue(
|
||||
"performRunExecution",
|
||||
{
|
||||
id: execution.id,
|
||||
},
|
||||
{
|
||||
queueName: `job:queue:${queueId}`,
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `execution:${execution.runId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export type EnqueueRunExecutionV2Options = {
|
||||
runAt?: Date;
|
||||
resumeTaskId?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Task, TaskAttempt } from "@trigger.dev/database";
|
||||
import type { JobRun, Task, TaskAttempt } from "@trigger.dev/database";
|
||||
import { CachedTask, ServerTask } from "@trigger.dev/core";
|
||||
|
||||
export type TaskWithAttempts = Task & { attempts: TaskAttempt[] };
|
||||
export type TaskWithAttempts = Task & { attempts: TaskAttempt[]; run: JobRun };
|
||||
|
||||
export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask {
|
||||
return {
|
||||
@@ -24,6 +24,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
operation: task.operation,
|
||||
callbackUrl: task.callbackUrl,
|
||||
forceYield: task.run.forceYieldImmediately,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
recurringTasks?: ZodRecurringTasks;
|
||||
cleanup?: ZodWorkerCleanupOptions;
|
||||
reporter?: ZodWorkerReporter;
|
||||
shutdownTimeoutInMs?: number;
|
||||
};
|
||||
|
||||
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
@@ -114,6 +115,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#runner?: GraphileRunner;
|
||||
#cleanup: ZodWorkerCleanupOptions | undefined;
|
||||
#reporter?: ZodWorkerReporter;
|
||||
#shutdownTimeoutInMs?: number;
|
||||
#shuttingDown = false;
|
||||
|
||||
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
|
||||
this.#name = options.name;
|
||||
@@ -124,6 +127,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#recurringTasks = options.recurringTasks;
|
||||
this.#cleanup = options.cleanup;
|
||||
this.#reporter = options.reporter;
|
||||
this.#shutdownTimeoutInMs = options.shutdownTimeoutInMs ?? 60000; // default to 60 seconds
|
||||
}
|
||||
|
||||
get graphileWorkerSchema() {
|
||||
@@ -143,6 +147,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
this.#runner = await graphileRun({
|
||||
...this.#runnerOptions,
|
||||
noHandleSignals: true,
|
||||
taskList: this.#createTaskListFromTasks(),
|
||||
parsedCronItems,
|
||||
});
|
||||
@@ -199,9 +204,34 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#logDebug("stop");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", this._handleSignal.bind(this));
|
||||
process.on("SIGINT", this._handleSignal.bind(this));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleSignal(signal: string) {
|
||||
if (this.#shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#shuttingDown = true;
|
||||
|
||||
if (this.#shutdownTimeoutInMs) {
|
||||
setTimeout(() => {
|
||||
this.#logDebug("Shutdown timeout reached, exiting process");
|
||||
|
||||
process.exit(0);
|
||||
}, this.#shutdownTimeoutInMs);
|
||||
}
|
||||
|
||||
this.#logDebug(`Received ${signal}, shutting down zodWorker...`);
|
||||
|
||||
this.stop().finally(() => {
|
||||
this.#logDebug("zodWorker stopped");
|
||||
});
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.#runner?.stop();
|
||||
}
|
||||
|
||||
@@ -100,21 +100,24 @@ export class RunListPresenter {
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? runs.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
}
|
||||
previous = cursor ? runs.at(1)?.id : undefined;
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
previous = runs[1]?.id;
|
||||
}
|
||||
previous = runs.at(1)?.id;
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
break;
|
||||
}
|
||||
|
||||
const runsToReturn =
|
||||
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
|
||||
|
||||
return {
|
||||
runs: runs.slice(0, PAGE_SIZE).map((run) => ({
|
||||
runs: runsToReturn.map((run) => ({
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
startedAt: run.startedAt,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const links: LinksFunction = () => {
|
||||
export const meta: TypedMetaFunction<typeof loader> = ({ data }) => ({
|
||||
title: `Trigger.dev${appEnvTitleTag(data?.appEnv)}`,
|
||||
charset: "utf-8",
|
||||
viewport: "width=device-width,initial-scale=1",
|
||||
viewport: "width=1024, initial-scale=1",
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
|
||||
@@ -60,7 +60,6 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
{impersonationId && <ImpersonationBanner impersonationId={impersonationId} />}
|
||||
<NoMobileOverlay />
|
||||
<AppContainer showBackgroundGradient={showBackgroundGradient}>
|
||||
<NavBar />
|
||||
<Outlet />
|
||||
@@ -72,7 +71,6 @@ export default function App() {
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<MainCenteredContainer>
|
||||
<RouteErrorDisplay />
|
||||
|
||||
@@ -30,9 +30,8 @@ export async function action({ request }: ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const headerClassName =
|
||||
"py-3 px-2 pr-3 text-xs font-semibold leading-tight text-slate-900 text-left";
|
||||
const cellClassName = "whitespace-nowrap px-2 py-2 text-xs text-slate-500";
|
||||
const headerClassName = "py-3 px-2 pr-3 text-xs font-semibold leading-tight text-bright text-left";
|
||||
const cellClassName = "whitespace-nowrap px-2 py-2 text-xs text-bright";
|
||||
|
||||
export default function AdminDashboardRoute() {
|
||||
const { users } = useTypedLoaderData<typeof loader>();
|
||||
@@ -46,8 +45,8 @@ export default function AdminDashboardRoute() {
|
||||
>
|
||||
<h1 className="mb-2 text-2xl">Accounts ({users.length})</h1>
|
||||
|
||||
<table className="w-full divide-y divide-slate-300">
|
||||
<thead className="sticky top-0 bg-white text-left outline outline-2 outline-slate-200">
|
||||
<table className="w-full divide-y divide-border">
|
||||
<thead className="sticky top-0 text-left">
|
||||
<tr>
|
||||
<th scope="col" className={headerClassName}>
|
||||
Email
|
||||
@@ -69,10 +68,10 @@ export default function AdminDashboardRoute() {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 bg-white">
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="w-full bg-white px-4 py-2 text-left hover:bg-slate-50">
|
||||
<tr key={user.id} className="w-full px-4 py-2 text-left hover:bg-slate-900">
|
||||
<td className={cellClassName}>{user.email}</td>
|
||||
<td className={cellClassName}>
|
||||
<a
|
||||
@@ -108,11 +107,6 @@ export default function AdminDashboardRoute() {
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* Secondary column (hidden on smaller screens) */}
|
||||
<aside className="hidden lg:order-first lg:block lg:flex-shrink-0">
|
||||
<div className="relative flex h-full w-96 flex-col overflow-y-auto border-r border-gray-200 bg-white"></div>
|
||||
</aside>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ActionArgs, json, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
|
||||
export async function action({ request }: ActionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminOrgMembers = await prisma.orgMember.findMany({
|
||||
where: {
|
||||
organizationId: authenticationResult.environment.organizationId,
|
||||
user: {
|
||||
admin: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminOrgMembers.length) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: any = await request.json();
|
||||
|
||||
await workerQueue.enqueue("simulate", {
|
||||
seconds: body.seconds,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { HomeIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { UserCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import type { LoaderArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { getUser, requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const navigation = [{ name: "Home", href: "/admin", icon: HomeIcon }];
|
||||
|
||||
export async function loader({ request }: LoaderArgs) {
|
||||
await requireUserId(request);
|
||||
@@ -27,162 +20,10 @@ export async function loader({ request }: LoaderArgs) {
|
||||
|
||||
export default function Page() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full">
|
||||
<Transition.Root show={mobileMenuOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-40 lg:hidden" onClose={setMobileMenuOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white focus:outline-none">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
<div className="pt-5 pb-4">
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<img
|
||||
className="h-8 w-auto"
|
||||
src="https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600"
|
||||
alt="Workflow"
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label="Sidebar" className="mt-5">
|
||||
<div className="space-y-1 px-2">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="group flex items-center rounded-md p-2 text-base font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
>
|
||||
<item.icon
|
||||
className="mr-4 h-6 w-6 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 border-t border-gray-200 p-4">
|
||||
<button className="group block flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<UserProfilePhoto user={data.user} className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-base font-medium text-gray-700 group-hover:text-gray-900">
|
||||
{data.user.displayName}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-500 group-hover:text-gray-700">
|
||||
Account Settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
<div className="w-14 flex-shrink-0" aria-hidden="true">
|
||||
{/* Force sidebar to shrink to fit close icon */}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex w-20 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-indigo-600">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-center bg-indigo-700 py-4">
|
||||
<img
|
||||
className="h-8 w-auto"
|
||||
src="https://tailwindui.com/img/logos/workflow-mark.svg?color=white"
|
||||
alt="Workflow"
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label="Sidebar" className="flex flex-col items-center space-y-3 py-6">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="flex items-center rounded-lg p-4 text-indigo-200 hover:bg-indigo-700"
|
||||
>
|
||||
<item.icon className="h-6 w-6" aria-hidden="true" />
|
||||
<span className="sr-only">{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 pb-5">
|
||||
<button className="flex w-full flex-shrink-0 flex-grow justify-center">
|
||||
<UserProfilePhoto user={data.user} className="block h-10 w-10" />
|
||||
<div className="sr-only">
|
||||
<p>{data.user.displayName}</p>
|
||||
<p>Account settings</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProfilePhoto({ user, className }: { user: User; className?: string }) {
|
||||
return user.avatarUrl ? (
|
||||
<img
|
||||
className={cn("rounded-full", className)}
|
||||
src={user.avatarUrl}
|
||||
alt={user.name ?? user.displayName ?? "User"}
|
||||
/>
|
||||
) : (
|
||||
<UserCircleIcon className={cn("text-gray-400", className)} />
|
||||
<div className="h-full w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { ActionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { TaskStatus } from "@trigger.dev/database";
|
||||
import {
|
||||
RunTaskBodyOutput,
|
||||
RunTaskBodyOutputSchema,
|
||||
ServerTask,
|
||||
JobRunStatusRecordSchema,
|
||||
StatusHistory,
|
||||
StatusHistorySchema,
|
||||
StatusUpdate,
|
||||
@@ -14,12 +11,8 @@ import {
|
||||
} from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { $transaction, PrismaClient, prisma } from "~/db.server";
|
||||
import { taskWithAttemptsToServerTask } from "~/models/task.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ulid } from "~/services/ulid.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime";
|
||||
import type { CompleteTaskBodyOutput, ServerTask } from "@trigger.dev/core";
|
||||
import { CompleteTaskBodyInputSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { taskWithAttemptsToServerTask } from "~/models/task.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
@@ -72,9 +72,9 @@ export async function action({ request, params }: ActionArgs) {
|
||||
}
|
||||
|
||||
export class CompleteRunTaskService {
|
||||
#prismaClient: PrismaClient;
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
constructor(prismaClient: PrismaClientOrTransaction = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class CompleteRunTaskService {
|
||||
): Promise<ServerTask | undefined> {
|
||||
// Using a transaction, we'll first check to see if the task already exists and return if if it does
|
||||
// If it doesn't exist, we'll create it and return it
|
||||
const task = await this.#prismaClient.$transaction(async (tx) => {
|
||||
const task = await $transaction(this.#prismaClient, async (tx) => {
|
||||
const existingTask = await tx.task.findUnique({
|
||||
where: {
|
||||
id,
|
||||
@@ -152,6 +152,7 @@ export class CompleteRunTaskService {
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
run: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,7 @@ export class FailRunTaskService {
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
run: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,7 @@ export class RunTaskService {
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
run: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+11
-5
@@ -1,20 +1,26 @@
|
||||
import { ActionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentParam: z.string(),
|
||||
endpointParam: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { environmentParam, endpointParam } = ParamsSchema.parse(params);
|
||||
export async function action({ params }: ActionArgs) {
|
||||
const { endpointParam } = ParamsSchema.parse(params);
|
||||
|
||||
try {
|
||||
const service = new IndexEndpointService();
|
||||
const result = await service.call(endpointParam, "MANUAL");
|
||||
await service.call(endpointParam, "MANUAL");
|
||||
|
||||
// Enqueue the endpoint to be probed in 10 seconds
|
||||
await workerQueue.enqueue(
|
||||
"probeEndpoint",
|
||||
{ id: endpointParam },
|
||||
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (e) {
|
||||
|
||||
@@ -97,6 +97,7 @@ export class EndpointApi {
|
||||
return {
|
||||
...pongResponse.data,
|
||||
triggerVersion: headers.data["trigger-version"],
|
||||
triggerSdkVersion: headers.data["trigger-sdk-version"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,6 +309,27 @@ export class EndpointApi {
|
||||
|
||||
return validateResponse.data;
|
||||
}
|
||||
|
||||
async probe(timeout: number) {
|
||||
const startTimeInMs = performance.now();
|
||||
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trigger-api-key": this.apiKey,
|
||||
"x-trigger-action": "PROBE_EXECUTION_TIMEOUT",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timeout,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
durationInMs: Math.floor(performance.now() - startTimeInMs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function safeFetch(url: string, options: RequestInit) {
|
||||
|
||||
@@ -127,16 +127,21 @@ export class PerformEndpointIndexService {
|
||||
}
|
||||
|
||||
const { jobs, sources, dynamicTriggers, dynamicSchedules } = bodyResult.data;
|
||||
const { "trigger-version": triggerVersion } = headerResult.data;
|
||||
const { "trigger-version": triggerVersion, "trigger-sdk-version": triggerSdkVersion } =
|
||||
headerResult.data;
|
||||
const { endpoint } = endpointIndex;
|
||||
|
||||
if (triggerVersion && triggerVersion !== endpoint.version) {
|
||||
if (
|
||||
(triggerVersion && triggerVersion !== endpoint.version) ||
|
||||
(triggerSdkVersion && triggerSdkVersion !== endpoint.sdkVersion)
|
||||
) {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id: endpoint.id,
|
||||
},
|
||||
data: {
|
||||
version: triggerVersion,
|
||||
sdkVersion: triggerSdkVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MAX_RUN_CHUNK_EXECUTION_LIMIT, RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { prisma, PrismaClient } from "~/db.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { detectResponseIsTimeout } from "~/models/endpoint.server";
|
||||
|
||||
export class ProbeEndpointService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const endpoint = await this.#prismaClient.endpoint.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
environment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Probing endpoint`, {
|
||||
id,
|
||||
});
|
||||
|
||||
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url);
|
||||
|
||||
const { response, durationInMs } = await client.probe(MAX_RUN_CHUNK_EXECUTION_LIMIT);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Probing endpoint complete`, {
|
||||
id,
|
||||
durationInMs,
|
||||
response: {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
},
|
||||
});
|
||||
|
||||
// If the response is a 200, or it was a timeout, we can assume the endpoint is up and update the runChunkExecutionLimit
|
||||
if (response.status === 200 || detectResponseIsTimeout(response)) {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
runChunkExecutionLimit: Math.min(
|
||||
Math.max(durationInMs, 10000),
|
||||
MAX_RUN_CHUNK_EXECUTION_LIMIT
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
class ForceYieldCoordinator {
|
||||
private inFlightRuns: Set<string> = new Set();
|
||||
private prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient) {
|
||||
this.prismaClient = prismaClient;
|
||||
|
||||
process.on("SIGTERM", this.handleForceYield);
|
||||
}
|
||||
|
||||
// Add a run to the in-flight set
|
||||
public registerRun(runId: string): void {
|
||||
this.inFlightRuns.add(runId);
|
||||
}
|
||||
|
||||
// Remove a run from the in-flight set
|
||||
public deregisterRun(runId: string): void {
|
||||
this.inFlightRuns.delete(runId);
|
||||
}
|
||||
|
||||
// Handle forced yield on SIGTERM
|
||||
private handleForceYield = async (): Promise<void> => {
|
||||
const runIds = Array.from(this.inFlightRuns);
|
||||
|
||||
const results = await this.prismaClient.jobRun.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: runIds,
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
data: {
|
||||
forceYieldImmediately: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`ForceYieldCoordinator: ${results.count}/${runIds.length} runs set to immediately force yield`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const forceYieldCoordinator = new ForceYieldCoordinator(prisma);
|
||||
@@ -1,771 +0,0 @@
|
||||
import {
|
||||
CachedTaskSchema,
|
||||
RunJobError,
|
||||
RunJobInvalidPayloadError,
|
||||
RunJobResumeWithTask,
|
||||
RunJobRetryWithTask,
|
||||
RunJobSuccess,
|
||||
RunJobUnresolvedAuthError,
|
||||
RunSourceContextSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Task } from "@trigger.dev/database";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { eventRecordToApiJson } from "~/api.server";
|
||||
import { EXECUTE_JOB_RETRY_LIMIT } from "~/consts";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV1 } from "~/models/jobRunExecution.server";
|
||||
import { resolveRunConnections } from "~/models/runConnection.server";
|
||||
import { formatError } from "~/utils/formatErrors.server";
|
||||
import { safeJsonZodParse } from "~/utils/json";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
type FoundRunExecution = NonNullable<Awaited<ReturnType<typeof findRunExecution>>>;
|
||||
|
||||
export class PerformRunExecutionV1Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const runExecution = await findRunExecution(this.#prismaClient, id);
|
||||
|
||||
if (!runExecution) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (runExecution.reason) {
|
||||
case "PREPROCESS": {
|
||||
await this.#executePreprocessing(runExecution);
|
||||
break;
|
||||
}
|
||||
case "EXECUTE_JOB": {
|
||||
await this.#executeJob(runExecution);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the preprocessing step of a run, which will send the payload to the endpoint and give the job
|
||||
// an opportunity to generate run properties based on the payload.
|
||||
// If the endpoint is not available, or the response is not ok,
|
||||
// the run execution will be marked as failed and the run will start
|
||||
async #executePreprocessing(execution: FoundRunExecution) {
|
||||
const { run } = execution;
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
const startedAt = new Date();
|
||||
|
||||
await this.#prismaClient.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const { response, parser } = await client.preprocessRunRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry(execution, {
|
||||
message: "Could not connect to the endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return await this.#failRunExecutionWithRetry(execution, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
if (safeBody.data.abort) {
|
||||
return this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
execution,
|
||||
{ message: "Endpoint aborted the run" },
|
||||
"ABORTED"
|
||||
);
|
||||
} else {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
properties: safeBody.data.properties,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const runExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV1(runExecution, run.queue.id, run.queue.maxJobs, tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
async #executeJob(execution: FoundRunExecution) {
|
||||
const { run, isRetry } = execution;
|
||||
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(execution);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
await this.#prismaClient.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt,
|
||||
run: {
|
||||
update: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecutionWithRetry(execution, {
|
||||
message: `Could not resolve all connections for run ${run.id}, attempting to retry`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (execution.resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: execution.resumeTaskId,
|
||||
},
|
||||
})) ?? undefined;
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id: execution.resumeTaskId,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
|
||||
const { response, parser, errorParser } = await client.executeJobRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
startedAt,
|
||||
isRetry,
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
connections: connections.auth,
|
||||
source: sourceContext.success ? sourceContext.data : undefined,
|
||||
tasks: [run.tasks, resumedTask]
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map((t) => CachedTaskSchema.parse(t)),
|
||||
yieldedExecutions: run.yieldedExecutions,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry(execution, {
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug("Endpoint responded with non-200 status code", {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
});
|
||||
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, errorBody.data);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(execution, errorBody.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(execution, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, execution, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(execution);
|
||||
break;
|
||||
}
|
||||
case "UNRESOLVED_AUTH_ERROR": {
|
||||
await this.#failRunWithUnresolvedAuthError(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "INVALID_PAYLOAD": {
|
||||
await this.#failRunWithInvalidPayloadError(execution, safeBody.data);
|
||||
|
||||
break;
|
||||
}
|
||||
case "YIELD_EXECUTION": {
|
||||
await this.#resumeYieldedExecution(execution, safeBody.data.key);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #completeRunWithSuccess(execution: FoundRunExecution, data: RunJobSuccess) {
|
||||
const { run } = execution;
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status: "SUCCESS",
|
||||
output: data.output ?? undefined,
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeYieldedExecution(execution: FoundRunExecution, key: string) {
|
||||
const { run } = execution;
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
completedAt: new Date(),
|
||||
run: {
|
||||
update: {
|
||||
yieldedExecutions: {
|
||||
push: key,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newJobExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV1(newJobExecution, run.queue.id, run.queue.maxJobs, tx);
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeRunWithTask(execution: FoundRunExecution, data: RunJobResumeWithTask) {
|
||||
const { run } = execution;
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "SUCCESS",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// If the task has an operation, then the next performRunExecution will occur
|
||||
// when that operation has finished
|
||||
// Tasks with callbacks enabled will also get processed separately, i.e. when
|
||||
// they time out, or on valid requests to their callbackUrl
|
||||
if (!data.task.operation && !data.task.callbackUrl) {
|
||||
const newJobExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
resumeTaskId: data.task.id,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV1(
|
||||
newJobExecution,
|
||||
run.queue.id,
|
||||
run.queue.maxJobs,
|
||||
tx,
|
||||
data.task.delayUntil ?? undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithError(execution: FoundRunExecution, data: RunJobError) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
if (data.task) {
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
completedAt: new Date(),
|
||||
output: data.error ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.#failRunExecution(tx, execution, data.error ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithUnresolvedAuthError(
|
||||
execution: FoundRunExecution,
|
||||
data: RunJobUnresolvedAuthError
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await this.#failRunExecution(tx, execution, data.issues, "UNRESOLVED_AUTH");
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunWithInvalidPayloadError(
|
||||
execution: FoundRunExecution,
|
||||
data: RunJobInvalidPayloadError
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await this.#failRunExecution(tx, execution, data.errors, "INVALID_PAYLOAD");
|
||||
});
|
||||
}
|
||||
|
||||
async #retryRunWithTask(execution: FoundRunExecution, data: RunJobRetryWithTask) {
|
||||
const { run } = execution;
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// We need to check for an existing task attempt
|
||||
const existingAttempt = await tx.taskAttempt.findFirst({
|
||||
where: {
|
||||
taskId: data.task.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
orderBy: {
|
||||
number: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAttempt) {
|
||||
await tx.taskAttempt.update({
|
||||
where: {
|
||||
id: existingAttempt.id,
|
||||
},
|
||||
data: {
|
||||
status: "ERRORED",
|
||||
error: formatError(data.error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// We need to create a new task attempt
|
||||
await tx.taskAttempt.create({
|
||||
data: {
|
||||
taskId: data.task.id,
|
||||
number: existingAttempt ? existingAttempt.number + 1 : 1,
|
||||
status: "PENDING",
|
||||
runAt: data.retryAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.task.update({
|
||||
where: {
|
||||
id: data.task.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING",
|
||||
},
|
||||
});
|
||||
|
||||
// Now we need to create a new job execution
|
||||
const newJobExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
resumeTaskId: data.task.id,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV1(
|
||||
newJobExecution,
|
||||
run.queue.id,
|
||||
run.queue.maxJobs,
|
||||
tx,
|
||||
data.retryAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunExecutionWithRetry(
|
||||
execution: FoundRunExecution,
|
||||
output: Record<string, any>
|
||||
): Promise<void> {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
if (execution.retryCount + 1 > execution.retryLimit) {
|
||||
// We've reached the retry limit, so we need to fail the execution and stop retrying
|
||||
return await this.#failRunExecution(tx, execution, output);
|
||||
}
|
||||
|
||||
// We need to retry execution
|
||||
const retryCount = execution.retryCount + 1;
|
||||
// Use an exponential backoff strategy with the exponent being 1.5
|
||||
// So when retryCount is 1, retryDelayInMs is 500ms
|
||||
// When retryCount is 2, retryDelayInMs is 750ms
|
||||
// When retryCount is 3, retryDelayInMs is 1125ms
|
||||
// When retryCount is 4, retryDelayInMs is 1687ms
|
||||
// When retryCount is 5, retryDelayInMs is 2531ms
|
||||
// When retryCount is 6, retryDelayInMs is 3796ms
|
||||
// When retryCount is 7, retryDelayInMs is 5694ms
|
||||
// When retryCount is 8, retryDelayInMs is 8541ms
|
||||
// When retryCount is 9, retryDelayInMs is 12812ms
|
||||
// When retryCount is 10, retryDelayInMs is 19218ms
|
||||
const retryDelayInMs = Math.round(500 * Math.pow(1.5, retryCount - 1));
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
retryCount,
|
||||
retryDelayInMs,
|
||||
error: JSON.stringify(output),
|
||||
},
|
||||
});
|
||||
|
||||
const runAt = new Date(Date.now() + retryDelayInMs);
|
||||
|
||||
await enqueueRunExecutionV1(
|
||||
execution,
|
||||
execution.run.queue.id,
|
||||
execution.run.queue.maxJobs,
|
||||
tx,
|
||||
runAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunExecution(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
execution: FoundRunExecution,
|
||||
output: Record<string, any>,
|
||||
status: "FAILURE" | "ABORTED" | "UNRESOLVED_AUTH" | "INVALID_PAYLOAD" = "FAILURE"
|
||||
): Promise<void> {
|
||||
const { run } = execution;
|
||||
|
||||
await $transaction(prisma, async (tx) => {
|
||||
switch (execution.reason) {
|
||||
case "EXECUTE_JOB": {
|
||||
// If the execution is an EXECUTE_JOB reason, we need to fail the run
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPROCESS": {
|
||||
// If the status is ABORTED, we need to fail the run
|
||||
if (status === "ABORTED") {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
queue: {
|
||||
update: {
|
||||
jobCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const runExecution = await tx.jobRunExecution.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
reason: "EXECUTE_JOB",
|
||||
status: "PENDING",
|
||||
retryLimit: EXECUTE_JOB_RETRY_LIMIT,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV1(runExecution, run.queue.id, run.queue.maxJobs, tx);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await tx.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "FAILURE",
|
||||
completedAt: new Date(),
|
||||
error: JSON.stringify(output),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #cancelExecution(execution: FoundRunExecution) {
|
||||
await this.#prismaClient.jobRunExecution.update({
|
||||
where: {
|
||||
id: execution.id,
|
||||
},
|
||||
data: {
|
||||
status: "FAILURE",
|
||||
completedAt: new Date(),
|
||||
error: "This never ran because it was canceled by the user.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function findRunExecution(prisma: PrismaClientOrTransaction, id: string) {
|
||||
return await prisma.jobRunExecution.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
run: {
|
||||
include: {
|
||||
environment: true,
|
||||
endpoint: true,
|
||||
organization: true,
|
||||
externalAccount: true,
|
||||
queue: true,
|
||||
runConnections: {
|
||||
include: {
|
||||
integration: true,
|
||||
connection: {
|
||||
include: {
|
||||
dataReference: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["COMPLETED"],
|
||||
},
|
||||
},
|
||||
},
|
||||
event: true,
|
||||
version: {
|
||||
include: {
|
||||
job: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BloomFilter,
|
||||
ConnectionAuth,
|
||||
EndpointHeadersSchema,
|
||||
RunJobAutoYieldWithCompletedTaskExecutionError,
|
||||
RunJobError,
|
||||
RunJobInvalidPayloadError,
|
||||
RunJobResumeWithTask,
|
||||
@@ -24,9 +25,17 @@ import { safeJsonZodParse } from "~/utils/json";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { prepareTasksForCaching, prepareTasksForCachingLegacy } from "~/models/task.server";
|
||||
import { MAX_RUN_YIELDED_EXECUTIONS } from "~/consts";
|
||||
import {
|
||||
MAX_RUN_CHUNK_EXECUTION_LIMIT,
|
||||
MAX_RUN_YIELDED_EXECUTIONS,
|
||||
RESPONSE_TIMEOUT_STATUS_CODES,
|
||||
RUN_CHUNK_EXECUTION_BUFFER,
|
||||
} from "~/consts";
|
||||
import { ApiEventLog } from "@trigger.dev/core";
|
||||
import { RunJobBody } from "@trigger.dev/core";
|
||||
import { CompleteRunTaskService } from "~/routes/api.v1.runs.$runId.tasks.$id.complete";
|
||||
import { detectResponseIsTimeout } from "~/models/endpoint.server";
|
||||
import { forceYieldCoordinator } from "./forceYieldCoordinator.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type FoundTask = FoundRun["tasks"][number];
|
||||
@@ -148,6 +157,7 @@ export class PerformRunExecutionV2Service {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
properties: safeBody.data.properties,
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -158,257 +168,291 @@ export class PerformRunExecutionV2Service {
|
||||
}
|
||||
}
|
||||
async #executeJob(run: FoundRun, input: PerformRunExecutionV2Input) {
|
||||
const { isRetry, resumeTaskId } = input;
|
||||
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof process.env.BLOCKED_ORGS === "string" &&
|
||||
process.env.BLOCKED_ORGS.includes(run.organizationId)
|
||||
) {
|
||||
logger.debug("Skipping execution for blocked org", {
|
||||
orgId: run.organizationId,
|
||||
});
|
||||
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const { isRetry, resumeTaskId } = input;
|
||||
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
try {
|
||||
if (
|
||||
typeof process.env.BLOCKED_ORGS === "string" &&
|
||||
process.env.BLOCKED_ORGS.includes(run.organizationId)
|
||||
) {
|
||||
logger.debug("Skipping execution for blocked org", {
|
||||
orgId: run.organizationId,
|
||||
});
|
||||
|
||||
const startedAt = new Date();
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const { executionCount } = await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
const { executionCount } = await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
})) ?? undefined;
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
})) ?? undefined;
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
|
||||
const executionBody = await this.#createExecutionBody(
|
||||
run,
|
||||
[run.tasks, resumedTask].flat().filter(Boolean),
|
||||
startedAt,
|
||||
isRetry,
|
||||
connections.auth,
|
||||
event,
|
||||
sourceContext.success ? sourceContext.data : undefined
|
||||
);
|
||||
|
||||
forceYieldCoordinator.registerRun(run.id);
|
||||
|
||||
const { response, parser, errorParser, durationInMs } = await client.executeJobRequest(
|
||||
executionBody
|
||||
);
|
||||
|
||||
forceYieldCoordinator.deregisterRun(run.id);
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the endpoint version if it has changed
|
||||
const rawHeaders = Object.fromEntries(response.headers.entries());
|
||||
const headers = EndpointHeadersSchema.safeParse(rawHeaders);
|
||||
|
||||
if (
|
||||
headers.success &&
|
||||
headers.data["trigger-version"] &&
|
||||
headers.data["trigger-version"] !== run.endpoint.version
|
||||
) {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
id: run.endpoint.id,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
version: headers.data["trigger-version"],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
const rawBody = await response.text();
|
||||
|
||||
const executionBody = await this.#createExecutionBody(
|
||||
run,
|
||||
[run.tasks, resumedTask].flat().filter(Boolean),
|
||||
startedAt,
|
||||
isRetry,
|
||||
connections.auth,
|
||||
event,
|
||||
sourceContext.success ? sourceContext.data : undefined
|
||||
);
|
||||
if (!response.ok) {
|
||||
logger.debug("Endpoint responded with non-200 status code", {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
});
|
||||
|
||||
const { response, parser, errorParser, durationInMs } = await client.executeJobRequest(
|
||||
executionBody
|
||||
);
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
}
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
errorBody.data
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(errorBody.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the endpoint version if it has changed
|
||||
const rawHeaders = Object.fromEntries(response.headers.entries());
|
||||
const headers = EndpointHeadersSchema.safeParse(rawHeaders);
|
||||
|
||||
if (
|
||||
headers.success &&
|
||||
headers.data["trigger-version"] &&
|
||||
headers.data["trigger-version"] !== run.endpoint.version
|
||||
) {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id: run.endpoint.id,
|
||||
},
|
||||
data: {
|
||||
version: headers.data["trigger-version"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
logger.debug("Endpoint responded with non-200 status code", {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
});
|
||||
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
errorBody.data
|
||||
{
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(errorBody.data);
|
||||
// If the error is a timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
|
||||
if (detectResponseIsTimeout(response)) {
|
||||
return await this.#resumeRunExecutionAfterTimeout(
|
||||
this.#prismaClient,
|
||||
run,
|
||||
input,
|
||||
durationInMs,
|
||||
executionCount
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
} else {
|
||||
// If the error is a 504 timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
|
||||
if (response.status === 504) {
|
||||
return await this.#resumeRunExecutionAfterTimeout(
|
||||
this.#prismaClient,
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(run);
|
||||
break;
|
||||
}
|
||||
case "UNRESOLVED_AUTH_ERROR": {
|
||||
await this.#failRunWithUnresolvedAuthError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "INVALID_PAYLOAD": {
|
||||
await this.#failRunWithInvalidPayloadError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "YIELD_EXECUTION": {
|
||||
await this.#resumeYieldedRun(
|
||||
run,
|
||||
input,
|
||||
safeBody.data.key,
|
||||
isRetry,
|
||||
durationInMs,
|
||||
executionCount
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "AUTO_YIELD_EXECUTION": {
|
||||
await this.#resumeAutoYieldedRun(
|
||||
run,
|
||||
safeBody.data,
|
||||
isRetry,
|
||||
durationInMs,
|
||||
executionCount
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "AUTO_YIELD_EXECUTION_WITH_COMPLETED_TASK": {
|
||||
await this.#resumeAutoYieldedRunWithCompletedTask(
|
||||
run,
|
||||
safeBody.data,
|
||||
isRetry,
|
||||
durationInMs,
|
||||
executionCount
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
},
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
const status = safeBody.data.status;
|
||||
|
||||
switch (status) {
|
||||
case "SUCCESS": {
|
||||
await this.#completeRunWithSuccess(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RESUME_WITH_TASK": {
|
||||
await this.#resumeRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
break;
|
||||
}
|
||||
case "ERROR": {
|
||||
await this.#failRunWithError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "RETRY_WITH_TASK": {
|
||||
await this.#retryRunWithTask(run, safeBody.data, isRetry, durationInMs, executionCount);
|
||||
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(run);
|
||||
break;
|
||||
}
|
||||
case "UNRESOLVED_AUTH_ERROR": {
|
||||
await this.#failRunWithUnresolvedAuthError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "INVALID_PAYLOAD": {
|
||||
await this.#failRunWithInvalidPayloadError(run, safeBody.data, durationInMs);
|
||||
|
||||
break;
|
||||
}
|
||||
case "YIELD_EXECUTION": {
|
||||
await this.#resumeYieldedRun(run, safeBody.data.key, isRetry, durationInMs, executionCount);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
} finally {
|
||||
forceYieldCoordinator.deregisterRun(run.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +502,13 @@ export class PerformRunExecutionV2Service {
|
||||
cachedTaskCursor: preparedTasks.cursor,
|
||||
noopTasksSet: prepareNoOpTasksBloomFilter(tasks),
|
||||
yieldedExecutions: run.yieldedExecutions,
|
||||
runChunkExecutionLimit: run.endpoint.runChunkExecutionLimit - RUN_CHUNK_EXECUTION_BUFFER,
|
||||
autoYieldConfig: {
|
||||
startTaskThreshold: run.endpoint.startTaskThreshold,
|
||||
beforeExecuteTaskThreshold: run.endpoint.beforeExecuteTaskThreshold,
|
||||
beforeCompleteTaskThreshold: run.endpoint.beforeCompleteTaskThreshold,
|
||||
afterCompleteTaskThreshold: run.endpoint.afterCompleteTaskThreshold,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -639,6 +690,7 @@ export class PerformRunExecutionV2Service {
|
||||
yieldedExecutions: {
|
||||
push: key,
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
select: {
|
||||
yieldedExecutions: true,
|
||||
@@ -654,6 +706,101 @@ export class PerformRunExecutionV2Service {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeAutoYieldedRun(
|
||||
run: FoundRun,
|
||||
data: { location: string; timeRemaining: number; timeElapsed: number; limit?: number },
|
||||
isRetry: boolean,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
) {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
autoYieldExecution: {
|
||||
create: [
|
||||
{
|
||||
location: data.location,
|
||||
timeRemaining: data.timeRemaining,
|
||||
timeElapsed: data.timeElapsed,
|
||||
limit: data.limit ?? 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
executionCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeAutoYieldedRunWithCompletedTask(
|
||||
run: FoundRun,
|
||||
data: RunJobAutoYieldWithCompletedTaskExecutionError,
|
||||
isRetry: boolean,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
) {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
autoYieldExecution: {
|
||||
create: [
|
||||
{
|
||||
location: data.data.location,
|
||||
timeRemaining: data.data.timeRemaining,
|
||||
timeElapsed: data.data.timeElapsed,
|
||||
limit: data.data.limit ?? 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new CompleteRunTaskService(tx);
|
||||
|
||||
await service.call(run.environment, run.id, data.id, {
|
||||
properties: data.properties,
|
||||
output: data.output,
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV2(run, tx, {
|
||||
isRetry,
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
executionCount,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #retryRunWithTask(
|
||||
run: FoundRun,
|
||||
data: RunJobRetryWithTask,
|
||||
@@ -748,6 +895,54 @@ export class PerformRunExecutionV2Service {
|
||||
return;
|
||||
}
|
||||
|
||||
const runWithLatestTask = await tx.jobRun.findUniqueOrThrow({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
select: {
|
||||
tasks: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
displayKey: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (runWithLatestTask._count.tasks === run._count.tasks) {
|
||||
const latestTask = runWithLatestTask.tasks[0];
|
||||
|
||||
const cause =
|
||||
latestTask?.status === "RUNNING"
|
||||
? `This is likely caused by task "${
|
||||
latestTask.displayKey ?? latestTask.name
|
||||
}" execution exceeding the function timeout`
|
||||
: "This is likely caused by executing code outside of a task that exceeded the function timeout";
|
||||
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Function timeout detected in ${
|
||||
durationInMs / 1000.0
|
||||
}s without any task creation. This is unexpected behavior and could lead to an infinite execution error because the run will never finish. ${cause}`,
|
||||
},
|
||||
"TIMED_OUT",
|
||||
durationInMs
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
@@ -756,6 +951,16 @@ export class PerformRunExecutionV2Service {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
endpoint: {
|
||||
update: {
|
||||
// Never allow the execution limit to be less than 10 seconds or more than MAX_RUN_CHUNK_EXECUTION_LIMIT
|
||||
runChunkExecutionLimit: Math.min(
|
||||
Math.max(durationInMs, 10000),
|
||||
MAX_RUN_CHUNK_EXECUTION_LIMIT
|
||||
),
|
||||
},
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -794,6 +999,20 @@ export class PerformRunExecutionV2Service {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
tasks: {
|
||||
updateMany: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["WAITING", "RUNNING", "PENDING"],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: status === "TIMED_OUT" ? "CANCELED" : "ERRORED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -855,7 +1074,12 @@ async function findRun(prisma: PrismaClientOrTransaction, id: string) {
|
||||
return await prisma.jobRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
environment: true,
|
||||
environment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
endpoint: true,
|
||||
organization: true,
|
||||
externalAccount: true,
|
||||
@@ -894,6 +1118,11 @@ async function findRun(prisma: PrismaClientOrTransaction, id: string) {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { generateSecret } from "./utils.server";
|
||||
import { ExtendedEndpoint, findEndpoint } from "~/models/endpoint.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class RegisterSourceServiceV2 {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
@@ -211,16 +212,22 @@ export class RegisterSourceServiceV2 {
|
||||
|
||||
// Collect the options that are no longer being used so we can remove them
|
||||
const newOptions = metadata.options;
|
||||
const orphanedOptions: Record<string, string[]> = {};
|
||||
for (const event of triggerSource.options) {
|
||||
const values = newOptions[event.name];
|
||||
if (values === undefined) {
|
||||
orphanedOptions[event.name] = [event.value];
|
||||
const orphanedOptions: Record<string, Set<string>> = {};
|
||||
for (const option of triggerSource.options) {
|
||||
const newValues = newOptions[option.name];
|
||||
|
||||
// initialize the set
|
||||
if (!orphanedOptions[option.name]) {
|
||||
orphanedOptions[option.name] = new Set();
|
||||
}
|
||||
|
||||
if (newValues === undefined) {
|
||||
orphanedOptions[option.name] = new Set([...orphanedOptions[option.name], option.value]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (values!.includes(event.value)) {
|
||||
orphanedOptions[event.name] = [...values, event.value];
|
||||
if (!newValues.includes(option.value)) {
|
||||
orphanedOptions[option.name] = new Set([...orphanedOptions[option.name], option.value]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +257,26 @@ export class RegisterSourceServiceV2 {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the orphaned options
|
||||
for (const [name, values] of Object.entries(orphanedOptions)) {
|
||||
for (const value of values) {
|
||||
await tx.triggerSourceOption.delete({
|
||||
where: {
|
||||
name_value_sourceId: {
|
||||
name,
|
||||
value,
|
||||
sourceId: triggerSource.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: triggerSource.id,
|
||||
orphanedOptions,
|
||||
orphanedOptions: Object.fromEntries(
|
||||
Object.entries(orphanedOptions).map(([name, values]) => [name, Array.from(values)])
|
||||
),
|
||||
};
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
@@ -284,9 +308,19 @@ export class RegisterSourceServiceV2 {
|
||||
}
|
||||
|
||||
const triggerIsActive = triggerSource.active;
|
||||
const triggerHasOrphanedEvents = Object.keys(orphanedOptions).length > 0;
|
||||
const triggerHasOrphanedEvents = Object.values(orphanedOptions).some(
|
||||
(values) => values.length > 0
|
||||
);
|
||||
const triggerHasUnregisteredEvents = triggerSource.options.some((option) => !option.registered);
|
||||
|
||||
logger.debug("Deciding whether to activate source", {
|
||||
triggerIsActive,
|
||||
triggerHasOrphanedEvents,
|
||||
triggerHasUnregisteredEvents,
|
||||
orphanedOptions,
|
||||
options: triggerSource.options,
|
||||
});
|
||||
|
||||
if (!triggerIsActive || triggerHasOrphanedEvents || triggerHasUnregisteredEvents) {
|
||||
// We need to re-activate the source, and there could be orphaned events
|
||||
await workerQueue.enqueue("activateSource", {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class PerformTaskOperationService {
|
||||
}
|
||||
|
||||
if (!task.operation) {
|
||||
return await this.#resumeTask(task, null);
|
||||
return await this.#resumeTask(task, null, 0);
|
||||
}
|
||||
|
||||
logger.debug("PerformTaskOperationService.call", { task });
|
||||
@@ -53,12 +53,16 @@ export class PerformTaskOperationService {
|
||||
|
||||
const { url, requestInit, retry } = fetchOperation.data;
|
||||
|
||||
const startTimeInMs = performance.now();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: requestInit?.method ?? "GET",
|
||||
headers: normalizeHeaders(requestInit?.headers ?? {}),
|
||||
body: requestInit?.body,
|
||||
});
|
||||
|
||||
const durationInMs = Math.floor(performance.now() - startTimeInMs);
|
||||
|
||||
const jsonBody = await safeJsonFromResponse(response);
|
||||
|
||||
logger.debug("PerformTaskOperationService.call.fetch", {
|
||||
@@ -68,6 +72,7 @@ export class PerformTaskOperationService {
|
||||
statusCode: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
jsonBody,
|
||||
durationInMs,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -91,7 +96,7 @@ export class PerformTaskOperationService {
|
||||
}
|
||||
}
|
||||
|
||||
return await this.#resumeTask(task, jsonBody);
|
||||
return await this.#resumeTask(task, jsonBody, durationInMs);
|
||||
}
|
||||
default: {
|
||||
await this.#resumeTaskWithError(task, {
|
||||
@@ -118,6 +123,8 @@ export class PerformTaskOperationService {
|
||||
|
||||
logger.debug("Calculating retry at for strategy", {
|
||||
strategy,
|
||||
status: response.status,
|
||||
retry,
|
||||
});
|
||||
|
||||
switch (strategy.strategy) {
|
||||
@@ -218,7 +225,7 @@ export class PerformTaskOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
async #resumeTask(task: NonNullable<FoundTask>, output: any) {
|
||||
async #resumeTask(task: NonNullable<FoundTask>, output: any, durationInMs: number) {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.taskAttempt.updateMany({
|
||||
where: {
|
||||
@@ -236,6 +243,13 @@ export class PerformTaskOperationService {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
output: output ? output : undefined,
|
||||
run: {
|
||||
update: {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import { DeliverEventService } from "./events/deliverEvent.server";
|
||||
import { InvokeDispatcherService } from "./events/invokeDispatcher.server";
|
||||
import { integrationAuthRepository } from "./externalApis/integrationAuthRepository.server";
|
||||
import { IntegrationConnectionCreatedService } from "./externalApis/integrationConnectionCreated.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { MissingConnectionCreatedService } from "./runs/missingConnectionCreated.server";
|
||||
import { PerformRunExecutionV1Service } from "./runs/performRunExecutionV1.server";
|
||||
import { PerformRunExecutionV2Service } from "./runs/performRunExecutionV2.server";
|
||||
import { StartRunService } from "./runs/startRun.server";
|
||||
import { DeliverScheduledEventService } from "./schedules/deliverScheduledEvent.server";
|
||||
@@ -22,6 +20,7 @@ import { ActivateSourceService } from "./sources/activateSource.server";
|
||||
import { DeliverHttpSourceRequestService } from "./sources/deliverHttpSourceRequest.server";
|
||||
import { PerformTaskOperationService } from "./tasks/performTaskOperation.server";
|
||||
import { ProcessCallbackTimeoutService } from "./tasks/processCallbackTimeout";
|
||||
import { ProbeEndpointService } from "./endpoints/probeEndpoint.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -76,12 +75,15 @@ const workerCatalog = {
|
||||
connectionCreated: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
probeEndpoint: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
simulate: z.object({
|
||||
seconds: z.number(),
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
performRunExecution: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
performRunExecutionV2: z.object({
|
||||
id: z.string(),
|
||||
reason: z.enum(["EXECUTE_JOB", "PREPROCESS"]),
|
||||
@@ -207,6 +209,7 @@ function getWorkerQueue() {
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.WORKER_CONCURRENCY,
|
||||
},
|
||||
shutdownTimeoutInMs: env.GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
schema: workerCatalog,
|
||||
recurringTasks: {
|
||||
// Run this every 5 minutes
|
||||
@@ -375,6 +378,21 @@ function getWorkerQueue() {
|
||||
});
|
||||
},
|
||||
},
|
||||
probeEndpoint: {
|
||||
priority: 10,
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
const service = new ProbeEndpointService();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
simulate: {
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, payload.seconds * 1000));
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -391,19 +409,9 @@ function getExecutionWorkerQueue() {
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
},
|
||||
shutdownTimeoutInMs: env.GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
schema: executionWorkerCatalog,
|
||||
tasks: {
|
||||
performRunExecution: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 1,
|
||||
handler: async (payload, job) => {
|
||||
// This is a legacy task that we don't use anymore, but needs to be here for backwards compatibility
|
||||
// TODO: remove this once all performRunExecution tasks have been processed
|
||||
const service = new PerformRunExecutionV1Service();
|
||||
|
||||
await service.call(payload.id);
|
||||
},
|
||||
},
|
||||
performRunExecutionV2: {
|
||||
priority: 0, // smaller number = higher priority
|
||||
maxAttempts: 12,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { hasIcon } from "@trigger.dev/companyicons";
|
||||
import { iconNames as namedIcons } from "~/components/primitives/NamedIcon";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
|
||||
export const isValidIcon = (icon?: string): boolean => {
|
||||
if (!icon) {
|
||||
return false;
|
||||
}
|
||||
return namedIcons.includes(icon) || hasIcon(icon);
|
||||
return namedIcons.includes(icon) || hasIcon(icon) || tablerIcons.has(icon);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+10
-11
@@ -40,7 +40,6 @@
|
||||
"@codemirror/view": "^6.5.0",
|
||||
"@conform-to/react": "^0.6.1",
|
||||
"@conform-to/zod": "^0.6.1",
|
||||
"@godaddy/terminus": "^4.12.1",
|
||||
"@headlessui/react": "^1.7.8",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@highlight-run/node": "^3.1.0",
|
||||
@@ -55,11 +54,11 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.5",
|
||||
"@remix-run/express": "1.19.2-pre.0",
|
||||
"@remix-run/node": "1.19.2-pre.0",
|
||||
"@remix-run/react": "1.19.2-pre.0",
|
||||
"@remix-run/serve": "1.19.2-pre.0",
|
||||
"@remix-run/server-runtime": "1.19.2-pre.0",
|
||||
"@remix-run/express": "1.19.2",
|
||||
"@remix-run/node": "1.19.2",
|
||||
"@remix-run/react": "1.19.2",
|
||||
"@remix-run/serve": "1.19.2",
|
||||
"@remix-run/server-runtime": "1.19.2",
|
||||
"@team-plain/typescript-sdk": "^2.2.0",
|
||||
"@trigger.dev/companyicons": "^1.5.14",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
@@ -87,7 +86,7 @@
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "^3.3.4",
|
||||
"postcss-import": "^14.1.0",
|
||||
"posthog-js": "^1.69.0",
|
||||
"posthog-js": "^1.83.0",
|
||||
"posthog-node": "^3.1.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -117,9 +116,9 @@
|
||||
"zod-validation-error": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "1.19.2-pre.0",
|
||||
"@remix-run/eslint-config": "1.19.2-pre.0",
|
||||
"@remix-run/testing": "^1.19.2-pre.0",
|
||||
"@remix-run/dev": "1.19.2",
|
||||
"@remix-run/eslint-config": "1.19.2",
|
||||
"@remix-run/testing": "^1.19.2",
|
||||
"@storybook/addon-backgrounds": "^7.0.7",
|
||||
"@storybook/addon-docs": "^7.0.12",
|
||||
"@storybook/addon-essentials": "^7.0.7",
|
||||
@@ -180,4 +179,4 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-18
@@ -3,7 +3,6 @@ import express from "express";
|
||||
import compression from "compression";
|
||||
import morgan from "morgan";
|
||||
import { createRequestHandler } from "@remix-run/express";
|
||||
import { createTerminus } from "@godaddy/terminus";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -61,25 +60,19 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
console.log(`✅ app ready: http://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Handle shutdowns gracefully
|
||||
createTerminus(server, {
|
||||
signals: ["SIGINT", "SIGTERM"],
|
||||
timeout: process.env.GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
? Number(process.env.GRACEFUL_SHUTDOWN_TIMEOUT)
|
||||
: 5000,
|
||||
onSignal: async () => {
|
||||
console.log("[terminus] onSignal: starting cleanup");
|
||||
},
|
||||
onShutdown: async () => {
|
||||
console.log("[terminus] onShutdown: cleanup finished, server is shutting down");
|
||||
},
|
||||
onSendFailureDuringShutdown: async () => {
|
||||
console.log(
|
||||
"[terminus] onSendFailureDuringShutdown: cleanup finished, server is shutting down"
|
||||
);
|
||||
},
|
||||
server.keepAliveTimeout = 65 * 1000;
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error("Error closing express server:", err);
|
||||
} else {
|
||||
console.log("Express server closed gracefully.");
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
require(BUILD_DIR);
|
||||
console.log(`✅ app ready (skipping http server)`);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM node:18.16.1-bullseye-slim AS pruner
|
||||
FROM node:18.18.2-bullseye-slim@sha256:a50436b44fe3eb2ff72696fb394ad3cae45f06cb2a7468c5ef3f012d23d9888f AS pruner
|
||||
|
||||
WORKDIR /triggerdotdev
|
||||
|
||||
@@ -7,7 +7,7 @@ RUN npx -q turbo@1.10.9 prune --scope=webapp --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
# Base strategy to have layer caching
|
||||
FROM node:18.16.1-bullseye-slim AS base
|
||||
FROM node:18.18.2-bullseye-slim@sha256:a50436b44fe3eb2ff72696fb394ad3cae45f06cb2a7468c5ef3f012d23d9888f AS base
|
||||
RUN apt-get update && apt-get install -y openssl dumb-init
|
||||
WORKDIR /triggerdotdev
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
@@ -50,7 +50,7 @@ RUN pnpm run generate
|
||||
RUN pnpm run build --filter=webapp...
|
||||
|
||||
# Runner
|
||||
FROM node:18.16.1-bullseye-slim AS runner
|
||||
FROM node:18.18.2-bullseye-slim@sha256:a50436b44fe3eb2ff72696fb394ad3cae45f06cb2a7468c5ef3f012d23d9888f AS runner
|
||||
RUN apt-get update && apt-get install -y openssl
|
||||
WORKDIR /triggerdotdev
|
||||
RUN corepack enable
|
||||
|
||||
@@ -9,7 +9,7 @@ networks:
|
||||
|
||||
services:
|
||||
db:
|
||||
container_name: db
|
||||
container_name: devdb
|
||||
image: postgres:14
|
||||
restart: always
|
||||
volumes:
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- 3030:3030
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
|
||||
@@ -14,4 +14,6 @@ cp node_modules/@prisma/engines/*.node apps/webapp/prisma/
|
||||
pnpm --filter webapp db:seed
|
||||
|
||||
cd /triggerdotdev/apps/webapp
|
||||
exec dumb-init pnpm run start:local
|
||||
# exec dumb-init pnpm run start:local
|
||||
NODE_PATH='/triggerdotdev/node_modules/.pnpm/node_modules' exec dumb-init node --max-old-space-size=8192 ./build/server.js
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
database-data:
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
external: false
|
||||
|
||||
services:
|
||||
db:
|
||||
container_name: devdb
|
||||
image: postgres:14
|
||||
restart: always
|
||||
volumes:
|
||||
- database-data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
networks:
|
||||
- app_network
|
||||
ports:
|
||||
- 5432:5432
|
||||
app:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
ports:
|
||||
- 3030:3030
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
SESSION_SECRET: secret123
|
||||
MAGIC_LINK_SECRET: secret123
|
||||
ENCRYPTION_KEY: secret123
|
||||
REMIX_APP_PORT: 3030
|
||||
PORT: 3030
|
||||
WORKER_ENABLED: "false"
|
||||
EXECUTION_WORKER_ENABLED: "false"
|
||||
networks:
|
||||
- app_network
|
||||
worker:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: ./docker/Dockerfile
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
DIRECT_URL: postgres://postgres:postgres@db:5432/postgres?schema=public
|
||||
SESSION_SECRET: secret123
|
||||
MAGIC_LINK_SECRET: secret123
|
||||
ENCRYPTION_KEY: secret123
|
||||
REMIX_APP_PORT: 3030
|
||||
PORT: 3030
|
||||
HTTP_SERVER_DISABLED: "true"
|
||||
networks:
|
||||
- app_network
|
||||
@@ -1,4 +1,4 @@
|
||||
<ResponseField name="key" type="string" required>
|
||||
Should be a stable and unique key inside the `run()`. See
|
||||
<ResponseField name="cacheKey" type="string" required>
|
||||
Should be a stable and unique cache key inside the `run()`. See
|
||||
[resumability](/documentation/concepts/resumability) for more information.
|
||||
</ResponseField>
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "The Client is how you interact with the API, through an Adaptor."
|
||||
|
||||
## Client
|
||||
|
||||
A Client is used to connect to a specific [Project](/documentation/concepts/projects) by using an [API Key](/documentation/concepts/environments-apikeys).
|
||||
A Client is used to connect to a specific [Project](/documentation/concepts/projects) by using an [API Key](/documentation/concepts/environments-endpoints).
|
||||
|
||||
Clients are created using the `TriggerClient` class.
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: "Limitations"
|
||||
---
|
||||
|
||||
There are a few limitations that are important to understand.
|
||||
|
||||
In the latest version:
|
||||
|
||||
- Runs on localhost are limited to 5 minutes.
|
||||
- On long-running servers (not serverless) Runs can be retried erroneously.
|
||||
- Compute intensive jobs are not well supported.
|
||||
|
||||
## Runs on localhost are limited to 5 minutes
|
||||
|
||||
When developing locally the [CLI dev command](/documentation/guides/cli#dev-command) uses [ngrok](https://ngrok.com/) so messages can be sent to your machine.
|
||||
|
||||
Ngrok has a timeout of 5 minutes on a Request/Response cycle. so, if a localhost Run takes longer than 5 minutes to complete, the Run will fail.
|
||||
|
||||
This limitation will be removed in the future by adding an alternative run strategy that works well on localhost and long-running servers. This won't use the request/response cycle.
|
||||
|
||||
## On long-running servers (not serverless) Runs can be retried erroneously
|
||||
|
||||
Currently the only way that Runs are performed is by a Request/Response cycle when `run` is called on a Job. This is optimized for serverless functions (where you have to use a Request/Response cycle), but not for long-running servers.
|
||||
|
||||
This limitation will be removed in the future by adding an alternative mode so Jobs works well on localhost and long-running servers. This won't use the request/response cycle.
|
||||
|
||||
## Compute intensive jobs are not well supported
|
||||
|
||||
Currently the only way that Runs are performed is inside a Request/Response cycle when `run` is called on a Job. This is not a good way to perform compute intensive jobs.
|
||||
|
||||
In the future we will add good support for compute intensive jobs.
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: "Limits"
|
||||
---
|
||||
|
||||
## General Limits
|
||||
|
||||
The following limits apply to the Trigger.dev Cloud service and users of the self-hosted version of Trigger.dev.
|
||||
|
||||
| | Hobby | Team | Self-hosted / Enterprise |
|
||||
| ----------------------------------------------------------------------- | --------- | ----------- | ------------------------- |
|
||||
| Team Members | Up to 2 | Up to 5 | Custom |
|
||||
| Projects | 1 | Up to 5 | Custom |
|
||||
| Jobs per Project | Up to 10 | Up to 50 | Custom |
|
||||
| Runs (per Month) | 5,000 | Up to 1m | Custom |
|
||||
| Run Log retention | 24 hours | 7 days | Custom |
|
||||
| Connected Integrations | Up to 50 | Up to 1000 | Custom |
|
||||
| [Tasks per Run](#tasks-per-runs) | Up to 250 | Up to 1000 | Custom |
|
||||
| [Concurrent Run Executions](#concurrent-run-executions) | Up to 10 | Up to 10 | Custom |
|
||||
| [Maximum Task Duration](#maximum-task-duration) | < 2m | < 2m | < Deployment Grace Period |
|
||||
| [Maximum Run Execution Duration](#maximum-total-run-execution-duration) | up to 15m | up to 2 hrs | Custom |
|
||||
| [Yielded Executions per Run](#yielded-executions-per-run) | Up to 100 | Up to 100 | Custom |
|
||||
|
||||
### Tasks per Run
|
||||
|
||||
For any individual Job Run, the number of Tasks that can be executed is limited to 250 for Hobby and 1000 for Team plans. This limit is enforced to prevent runaway Jobs from consuming excessive resources.
|
||||
|
||||
#### What is a Task?
|
||||
|
||||
Tasks are the fundamental building blocks on which the Trigger.dev service is constructed. You can create and run a task using [io.runTask()](/sdk/io/runtask):
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "task-example",
|
||||
name: "Task Example",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({ name: "task.example" }),
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.runTask("task-1", async (task) => {
|
||||
// Do some work here
|
||||
return { foo: "bar" };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Tasks power the following features as well:
|
||||
|
||||
- [io.wait()](/sdk/io/wait)
|
||||
- [io.sendEvent()](/sdk/io/sendevent)
|
||||
- [io.backgroundFetch()](/sdk/io/backgroundfetch)
|
||||
- [io.logger](/sdk/io/logger)
|
||||
|
||||
Our integration clients are also built on top of Tasks, so any time you call an integration client method, you are creating a Task. e.g.:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-resend-email",
|
||||
name: "Send Resend Email",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({
|
||||
name: "send.email",
|
||||
}),
|
||||
integrations: {
|
||||
resend,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
// This creates a Task
|
||||
await io.resend.sendEmail("send-email", {
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
from: "Trigger.dev <hello@email.trigger.dev>",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Anything that shows up as an item on the [Run Log](/documentation/guides/viewing-runs#run-page) is a Task:
|
||||
|
||||

|
||||
|
||||
### Concurrent Run Executions
|
||||
|
||||
A Run Execution is a single HTTP request from the Trigger.dev server to your endpoint to execute a run. The number of concurrent Run Executions is limited to 10 for Hobby and Team plans.
|
||||
|
||||
This does not include runs that are waiting for a [io.wait()](/sdk/io/wait) to complete, so you could in theory have 1000s of "In Progress" jobs at a given time with no current run executions.
|
||||
|
||||
Going over this limit does not abort or cancel runs, but it will prevent new run executions until the number of concurrent executions drops below the limit.
|
||||
|
||||
### Maximum Task Duration
|
||||
|
||||
The Maximum Task Duration is the maximum amount of time a single Task can run for. This limit is partly enforced by the Trigger.dev server, but also by the execution runtime of your deployed serverless function.
|
||||
|
||||
For example, if you're deploying to Vercel and using their Node.js Serverless functions, the maximum execution time is anywhere from 1 second to 5 minutes. If you have a single task that can run for longer than your maximum function execution time, it will never complete.
|
||||
|
||||
We will retry tasks that never complete due to a timeout, but if the task continues to not complete, it will be marked as cancelled and the run will be timed out with an output like the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Function timeout detected in 10s without any task creation. This is unexpected behavior and could lead to an infinite execution error because the run will never finish. This is likely caused by task \"initial-long-task\" execution exceeding the function timeout"
|
||||
}
|
||||
```
|
||||
|
||||
See our Next.js section on [Deployment](/documentation/guides/platforms/nextjs#deployment) for more information on how to configure your function timeout.
|
||||
|
||||
Additionally, the Trigger.dev enforces a soft-cap of 2 minutes. Tasks that take longer than 2 minutes will be allowed to complete but we cannot guarentee that they won't be retried erroneously or cause your run execution to be locked for up to 4 hours. This is because of a current limitation of [Graphile Worker](https://github.com/graphile/worker) and our deployment platform.
|
||||
|
||||
### Maximum Total Run Execution Duration
|
||||
|
||||
The Maximum Total Run Execution Duration is the maximum amount of time a single run can execute for. Runs are completed over 1 or more executions, depending on many factors like the number of tasks, the serverless function timeout, task errors and delays. The Trigger.dev measures the total time spent across all run executions and will cancel the run if it exceeds the limit.
|
||||
|
||||
Hobby plans have a limit of 15 minutes, Team plans have a limit of 2 hours, and Enterprise plans can set a custom limit.
|
||||
|
||||
### Yielded Executions per Run
|
||||
|
||||
You can manually yield a run execution using `io.yield()`, which will exit the current run execution and schedule a new run execution to continue the run. You do this at most 100 times per run.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Projects"
|
||||
description: "Projects are a group of [Jobs](/documentation/concepts/jobs-runs-tasks) with [Environments](/documentation/concepts/environments-apikeys)"
|
||||
description: "Projects are a group of [Jobs](/documentation/concepts/jobs) with [Environments](/documentation/concepts/environments-endpoints)"
|
||||
---
|
||||
|
||||
User's are members of one or more Organizations. Each Organization can have many Projects.
|
||||
|
||||
@@ -8,14 +8,14 @@ description: "Runs are resumable by returning Task stored data"
|
||||
## How does this work?
|
||||
|
||||
1. When a Run is created, it is given a unique ID. This ID is used to identify the Run.
|
||||
2. [Tasks](/documentation/concepts/tasks) have a `key` which is a string and is the first parameter. This should be stable and unique inside that `run` function.
|
||||
2. [Tasks](/documentation/concepts/tasks) have a `cacheKey` which is a string and is the first parameter. This should be stable and unique inside that `run` function.
|
||||
3. When a Task is completed, its output is stored.
|
||||
4. If a Run exceeds the timeout, or your server restarts, the Run will be "replayed".
|
||||
5. The second+ time it is run, Tasks that have already successfully completed will immediately return their first output. The code inside them won't re-run.
|
||||
|
||||
## How to use keys
|
||||
## How to use cache keys
|
||||
|
||||
Like we mentioned above, we use Task keys to determine which Tasks have already been executed. They are defined by you inside your `run` function, for example when you call `io.slack.postMessage`:
|
||||
Like we mentioned above, we use Task cache keys to determine which Tasks have already been executed. They are defined by you inside your `run` function, for example when you call `io.slack.postMessage`:
|
||||
|
||||
```ts
|
||||
await io.slack.postMessage("⭐️ New Star", {
|
||||
@@ -24,9 +24,9 @@ await io.slack.postMessage("⭐️ New Star", {
|
||||
});
|
||||
```
|
||||
|
||||
In this example, the key is the string `"⭐️ New Star"`. This means that if the Job is interrupted and then resumed, the `slack.postMessage` Task will be skipped because it has already been executed.
|
||||
In this example, the cache key is the string `"⭐️ New Star"`. This means that if the Job is interrupted and then resumed, the `slack.postMessage` Task will be skipped because it has already been executed.
|
||||
|
||||
If you make multiple calls to `slack.postMessage`, you should use different keys for each call. For example:
|
||||
If you make multiple calls to `slack.postMessage`, you should use different cache keys for each call. For example:
|
||||
|
||||
```ts
|
||||
await io.slack.postMessage("⭐️ New Star", {
|
||||
@@ -42,7 +42,9 @@ await io.slack.postMessage("🚨 Critical Issue", {
|
||||
|
||||
If you are calling a Task multiple times with the same key, it will only be executed once. For example, if you call `slack.postMessage` with the key `"⭐️ New Star"` twice, it will only be executed once.
|
||||
|
||||
## How to use keys with loops
|
||||
See our [Task concept guide](/documentation/concepts/tasks) for more information about tasks and how they are crucial to the resumability of your Jobs.
|
||||
|
||||
## How to use cache keys with loops
|
||||
|
||||
If you are using a loop, you should use the loop index as the key. For example:
|
||||
|
||||
@@ -56,3 +58,8 @@ for (let i = 0; i < 10; i++) {
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
We don't currently support running tasks in parallel so `Promise.all` will not work correctly.
|
||||
This is something on our roadmap that we hope to support soon.
|
||||
</Warning>
|
||||
|
||||
@@ -56,7 +56,7 @@ A few things you can do with `io`:
|
||||
- Use [Integrations](/documentation/concepts/integrations).
|
||||
- Add [delays](/documentation/concepts/delays) (that can be longer than your server timeout).
|
||||
- Log messages to the [Run log](/documentation/guides/viewing-runs).
|
||||
- Perform [background fetch requests](/documentation/sdk/io) (that can be longer than your server timeout).
|
||||
- Perform [background fetch requests](/sdk/io/backgroundfetch) (that can be longer than your server timeout).
|
||||
- [Send events](/documentation/concepts/triggers/events) to Trigger other Jobs.
|
||||
- Create a [Task](/documentation/concepts/tasks) manually by wrapping code in `io.runTask`.
|
||||
|
||||
|
||||
@@ -3,17 +3,24 @@ title: "Tasks"
|
||||
description: "Tasks are individual building blocks of a Run."
|
||||
---
|
||||
|
||||
> A Task is a resumable unit of a Run that can be retried, resumed and is logged.
|
||||
A [Task](/documentation/concepts/tasks) is a cached unit of work in a Job Run that are logged to the Trigger.dev UI.
|
||||
|
||||
## Tasks vs regular code
|
||||
<Warning>
|
||||
Any interaction with an external service (database or API) should be wrapped in a Task. Failing to
|
||||
do so could result in repeated work when runs are resumed.
|
||||
</Warning>
|
||||
|
||||
In the `run()` function you can use regular code and you can use Tasks.
|
||||
## Why do you need tasks?
|
||||
|
||||
Tasks are a key building block of how Trigger.dev works, and failing to use them will result in unpredictable results. Tasks allow bits of work inside a Job Run to be cached and the results of those tasks to be reused.
|
||||
|
||||
This is **very important** because for a Job Run to be resumable (e.g. after a serverless function timeout, or because of a call to `io.wait()`), we need to call the `Job.run` function multiple times. If we didn't cache the results of Tasks, then we would be repeating work on each run.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "new-user",
|
||||
name: "Run when a new user signs up",
|
||||
version: "0.0.1",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "new.user",
|
||||
schema: z.object({
|
||||
@@ -24,26 +31,24 @@ client.defineJob({
|
||||
resend,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
// regular code, not a Task
|
||||
// the inputs/outputs of this function are not sent to the Trigger.dev platform
|
||||
const user = await prisma.user.findUnique({
|
||||
// This code will run twice. Once when the run first starts, and once after the wait
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: payload.userId },
|
||||
select: { email: true, name: true },
|
||||
});
|
||||
if (!user) throw new Error(`User not found: ${payload.userId}`);
|
||||
|
||||
// Integration functions are Tasks
|
||||
await io.resend.sendEmail("Welcome email", {
|
||||
// This code will run once, because the resend integration creates a task with the "welcome-email" cacheKey
|
||||
await io.resend.sendEmail("welcome-email", {
|
||||
to: user.email,
|
||||
from: "jane@acme.inc",
|
||||
subject: "Welcome!",
|
||||
html: welcomeEmail(user.name),
|
||||
});
|
||||
|
||||
// built-in io functions are Tasks
|
||||
// This code will run once, because io.wait creates a task with the "wait" cacheKey
|
||||
await io.wait("wait", 60 * 60 * 3); // wait for 3 hours
|
||||
|
||||
// You can wrap your own code in a Task, for retrying, resumability and logging
|
||||
// This code will run once, because we're manually creating a task with the "my-task" cacheKey
|
||||
const response = await io.runTask(
|
||||
"my-task",
|
||||
async () => {
|
||||
@@ -57,21 +62,246 @@ client.defineJob({
|
||||
});
|
||||
```
|
||||
|
||||
## The benefits of Tasks
|
||||
As well as powering the resumable nature of Trigger.dev, Tasks also provide:
|
||||
|
||||
Tasks are a powerful concept that gives you a lot of benefits:
|
||||
|
||||
- **Resumability** – Runs can exceed the maximum timeout on serverless platforms. If a Run exceeds this limit, it will be re-run. When it is re-run, any completed Tasks return their original output and they aren't re-run. Read more about [Resumability](/documentation/concepts/resumability).
|
||||
- **Retryable** – If a Task fails, it will be retried. You can configure how (or if) a Task is retried. Full details in the [io SDK reference](/sdk/io).
|
||||
- **Retryable** – If a Task fails, it can be retried. You can configure how (or if) a Task is retried. Full details in the [io.runTask() SDK reference](/sdk/io/runtask).
|
||||
- **Logging** – Tasks are logged, so you can see what happened in a Run. Find out more about [viewing runs](/documentation/guides/viewing-runs).
|
||||
|
||||
## Task Cache Keys
|
||||
|
||||
The first param of all Tasks is a `cacheKey`. This is a unique identifier for the Task inside that Run. It is used for storing the cached result of a task. It is also used to identify the Task in the [Viewing Runs Dashboard](/documentation/guides/viewing-runs).
|
||||
|
||||
<Warning>It's important that cacheKey's are unique inside an individual Job Run.</Warning>
|
||||
|
||||
## Creating Tasks
|
||||
|
||||
There are **3** ways of using tasks in your code:
|
||||
|
||||
- Using the [io.runTask()](/sdk/io/runtask) function
|
||||
- Using one of our [Integration packages](/documentation/concepts/integrations) and calling the `io.integration.runTask()` wrapper
|
||||
- Using one of our [Integration packages](/documentation/concepts/integrations) and calling a task wrapper function, such as [io.slack.postMessage()](/integrations/apis/slack)
|
||||
|
||||
### Using `io.runTask()`
|
||||
|
||||
The `io.runTask()` function allows you to run a Task manually. It takes a `cacheKey` and a function to run. The function will only be run if the Task is not already cached.
|
||||
|
||||
```ts
|
||||
const response = await io.runTask("my-task", async (task) => {
|
||||
return await longRunningCode(payload.userId);
|
||||
});
|
||||
```
|
||||
|
||||
The callback function is passed a `task` object, which can be useful for providing an idempotency key to an external service. For example, Stripe:
|
||||
|
||||
<Info>
|
||||
Our [Stripe Integration](integrations/apis/stripe) handles this for you automatically, this is
|
||||
just for documentation purposes
|
||||
</Info>
|
||||
|
||||
```ts
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
|
||||
await io.runTask("create-customer", async (task) => {
|
||||
await stripe.customers.create(
|
||||
{
|
||||
email: "eric@trigger.dev",
|
||||
},
|
||||
{
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
}
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
`runTask` also takes an optional 3rd argument, which allows you to customize how the Task is displayed and run. For example, you can supply a name and some properties to be displayed in the Viewing Runs Dashboard:
|
||||
|
||||
```ts
|
||||
const response = await io.runTask(
|
||||
"my-task",
|
||||
async (task) => {
|
||||
return await longRunningCode(payload.userId);
|
||||
},
|
||||
{
|
||||
name: "My Task",
|
||||
properties: [
|
||||
{
|
||||
label: "User ID",
|
||||
value: payload.userId,
|
||||
},
|
||||
],
|
||||
icon: "user",
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
See the [io.runTask() SDK reference](/sdk/io/runtask) for more information.
|
||||
|
||||
### Using `io.integration.runTask()`
|
||||
|
||||
All of our [Integration packages](/documentation/concepts/integrations) expose a `runTask()` function. The main differences between this and `io.runTask()` are:
|
||||
|
||||
- Adds an additional callback parameter which provides the underlying authenticated integration client
|
||||
- Automatically sets the `icon` property on the Task.
|
||||
- Configures sensible defaults for retries and error handling.
|
||||
|
||||
An example here demonstrates using the GitHub integration's `runTask` function to create a project card when a new user signs up:
|
||||
|
||||
```ts
|
||||
import { Github } from "@trigger.dev/github";
|
||||
|
||||
const github = new Github({
|
||||
id: "github",
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "create-project-card",
|
||||
name: "Create Project Card",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "new.user",
|
||||
}),
|
||||
integrations: {
|
||||
github,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.github.runTask(
|
||||
"create-card",
|
||||
async (client, task) => {
|
||||
// client is an authenticated GitHub client (https://github.com/octokit/octokit.js)
|
||||
return client.rest.projects.createCard({
|
||||
column_id: process.env.GITHUB_PROJECT_COLUMN_ID,
|
||||
note: `New User ${payload.user.name} signed up!`,
|
||||
});
|
||||
},
|
||||
{ name: "Create card" }
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Using an Integration Task Wrapper Function
|
||||
|
||||
Our [Integration packages](/documentation/concepts/integrations) also expose a number of task wrapper functions. These are functions that wrap a common task for that integration. For example, the [Slack integration](/integrations/apis/slack) exposes a `postMessage()` function:
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
|
||||
const slack = new Slack({
|
||||
id: "slack",
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "send-welcome-message",
|
||||
name: "Send welcome message",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "new.user",
|
||||
}),
|
||||
integrations: {
|
||||
slack,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await io.slack.postMessage("send-message", {
|
||||
channel: process.env.SLACK_CHANNEL_ID,
|
||||
text: `New user ${payload.user.name} signed up!`,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
All task wrapper functions take a `cacheKey` as the first argument, because they are Tasks under the hood. Think of them as a convenience wrapper around `io.runTask()`.
|
||||
|
||||
We strive to document all of the task wrapper functions in our [Integration packages](/documentation/concepts/integrations). For example, checkout our [GitHub integration task](/integrations/apis/github-tasks) docs.
|
||||
|
||||
## Subtasks
|
||||
|
||||
A Task can have multiple subtasks, and so on. This is useful for breaking down a large Task into smaller Tasks. We currently support nesting 5 levels deep.
|
||||
You can break up a task into multiple subtasks. This is useful for breaking up a long-running task into smaller chunks, while consolidating the logging into a single task in the dashboard with children.
|
||||
|
||||
## Task Keys
|
||||
<Info>We currently support nesting up to 5 levels</Info>
|
||||
|
||||
The first param of all Tasks is a `key`. This is a unique identifier for the Task inside that Run. It is used for resumability and logging. It is also used to identify the Task in the [Viewing Runs Dashboard](/documentation/guides/viewing-runs).
|
||||
```ts
|
||||
const response = await io.runTask("parent-task", async (task) => {
|
||||
await io.runTask("child-1", async () => {
|
||||
// do something
|
||||
});
|
||||
|
||||
await io.runTask("child-2", async () => {
|
||||
// do something
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Task cacheKey's are automatically scoped to the parent task. So for example, you can reuse a cacheKey inside a parent task and it will not conflict with another top-level task.
|
||||
|
||||
```ts
|
||||
const response = await io.runTask("parent-task", async (task) => {
|
||||
await io.runTask("child-1", async () => {
|
||||
// do something
|
||||
});
|
||||
|
||||
await io.runTask("child-2", async () => {
|
||||
// do something
|
||||
});
|
||||
});
|
||||
|
||||
// This will not conflict with the child-1 task above
|
||||
const response = await io.runTask("child-1", async (task) => {
|
||||
// do something
|
||||
});
|
||||
```
|
||||
|
||||
### Extracting Common Tasks
|
||||
|
||||
Subtasks allow you to DRY up any repeating task code into a single function. For example, if you have a common task that sends a welcome email, you can extract that into a function:
|
||||
|
||||
```ts
|
||||
const sendWelcomeEmail = async (cacheKey: string, io: IO, resend: Resend, userId: string) => {
|
||||
return await io.runTask(cacheKey, async () => {
|
||||
const user = await io.runTask("fetch-user", async () => {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true },
|
||||
});
|
||||
});
|
||||
|
||||
await io.resend.sendEmail("📧", {
|
||||
to: user.email,
|
||||
from: "eric@trigger.dev",
|
||||
subject: "Welcome!",
|
||||
html: welcomeEmail(user.name),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
client.defineJob({
|
||||
id: "new-user",
|
||||
name: "Run when a new user signs up",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "new.user",
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
}),
|
||||
integrations: {
|
||||
resend,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
await sendWelcomeEmail("🫡", io, io.resend, payload.userId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
Always make sure you are allow passing a unique cacheKey to the `runTask` function, so the tasks
|
||||
inside the function are not accidentally reused.
|
||||
</Note>
|
||||
|
||||
## Limitations
|
||||
|
||||
A single task has an upper-bound on it's execution duration, which must be less than the serverless function execution timeout of your deployed platform. For more information see our [Limits docs](/documentation/concepts/limits#maximum-task-duration)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Event triggers take a [Zod](https://github.com/colinhacks/zod) schema. This is u
|
||||
|
||||
It also means that inside your run function the payload will be typed correctly. We use [Zod](https://github.com/colinhacks/zod#installation) for our schemas – it's a fantastic library that allows you to define schemas in a very simple way.
|
||||
|
||||
You can always start out by using `z.any()` as your schema, and then later on you can add more strict validation. See our [Zod guide](/guides/zod) for more information.
|
||||
You can always start out by using `z.any()` as your schema, and then later on you can add more strict validation. See our [Zod guide](/documentation/guides/zod) for more information.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Webhooks can be difficult to work with, especially when developing locally. We m
|
||||
|
||||
There are two ways to use webhooks with Trigger.dev:
|
||||
|
||||
1. Use one of our built-in Integrations, such as [GitHub](/integrations/github). We'll take care of registering the webhook for you.
|
||||
1. Use one of our built-in Integrations, such as [GitHub](/integrations/apis/github). We'll take care of registering the webhook for you.
|
||||
2. [Create your own Integration](/integrations/create) that registers for webhooks, this is useful if you want to use a service that we don't have an Integration for.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -10,7 +10,7 @@ It can be used from _any_ Node.js (support versions) or TypeScript backend appli
|
||||
## What we take care of for you:
|
||||
|
||||
- We make it possible to run long-running Jobs on serverless platforms that have short timeouts (e.g. 30 seconds).
|
||||
- We provide an SDK for building Jobs in your codebase, triggered by various sources such as [events](/triggers/events), [scheduled events](/triggers/scheduled-events), and [webhooks](/triggers/webhooks).
|
||||
- We provide an SDK for building Jobs in your codebase, triggered by various sources such as [events](/documentation/concepts/triggers/events), [scheduled events](/documentation/concepts/triggers/scheduled), and [webhooks](/documentation/concepts/triggers/webhooks).
|
||||
- We provide an orchestration platform for running Jobs in your codebase.
|
||||
- We provide out-of-the-box Integrations with popular services such as [Slack](/integrations/apis/slack), [OpenAI](/integrations/apis/openai), [GitHub](/integrations/apis/github) and [more](/integrations), which vastly simplifies the process interacting with 3rd-party services.
|
||||
- We handle OAuth for you
|
||||
@@ -73,16 +73,16 @@ client.defineJob({
|
||||
|
||||
This code lives in a file inside your project repo.
|
||||
|
||||
It is listening for the [issueEvent](/integrations/apis/github/triggers) GitHub webhook, and when it receives one, we will take care of calling the `run` function supplied to the `Job` constructor with the webhook payload. This gives you the following advantages over traditional webhooks:
|
||||
It is listening for the [issueEvent](/integrations/apis/github-triggers#onissue) GitHub webhook, and when it receives one, we will take care of calling the `run` function supplied to the `Job` constructor with the webhook payload. This gives you the following advantages over traditional webhooks:
|
||||
|
||||
- We will automatically register the webhook with GitHub for you, and verify the payload signature.
|
||||
- We provide a nicely typed `event` payload to your `run` function, so you don't have to setup webhook payload types.
|
||||
- If your server isn't running, we will wait until it's back online before attempting to run the Job.
|
||||
- It is very easy to test your Job locally using our [Test Run](/guides/running-tests) feature.
|
||||
- It is very easy to test your Job locally using our [Test Run](/documentation/guides/testing-jobs) feature.
|
||||
|
||||
As you can see the above Job also makes a call to our Slack [postMessage](/integrations/apis/slack) function, which provides the following advantages over using the raw Slack API:
|
||||
|
||||
- We automatically handle the OAuth flow for you, so you don't have to worry about setting up a Slack app and dealing with credentials in your code (see our [Authentication](/concepts/authentication) guide for more details).
|
||||
- We automatically handle the OAuth flow for you, so you don't have to worry about setting up a Slack app and dealing with credentials in your code (see our [Authentication](/documentation/concepts/integrations#authentication) guide for more details).
|
||||
- We will automatically retry the request if the Slack API returns an error.
|
||||
- We provide a nicely typed `response` object from your `postMessage` function, so you don't have to setup Slack API types.
|
||||
|
||||
@@ -103,3 +103,19 @@ Below is a simplified architecture diagram of how Trigger.dev works:
|
||||

|
||||
|
||||
As you can see above, we communicate between your code and the Trigger.dev platform. This allows us to send events to your code, and receive tasks from your code.
|
||||
|
||||
## Limitations
|
||||
|
||||
There are a few limitations that are important to understand.
|
||||
|
||||
In the latest version the following are not supported:
|
||||
|
||||
### Long-running servers
|
||||
|
||||
Currently Trigger.dev is optimized for deployment to serverless functions, but not for long-running servers.
|
||||
|
||||
This limitation will be removed in the future by adding an alternative mode so Jobs works well on localhost and long-running servers.
|
||||
|
||||
### Compute intensive tasks
|
||||
|
||||
Because Trigger.dev is optimized for serverless functions, it is not well suited for compute intensive jobs as each individual task is limited to the [Maximum Run Chunk Execution Duration](/placeholder)
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
---
|
||||
title: "Create a Job"
|
||||
description: "How to create a Job in your codebase"
|
||||
---
|
||||
|
||||
> Jobs are the core of the system. They allow you to run code when some event occurs. They are built using a combination of Triggers and Tasks.
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
Make sure your Project is set up with Trigger.dev. We recommend [using the CLI](/documentation/quickstart) to do this.
|
||||
|
||||
## How to write a Job in code
|
||||
|
||||
### 1. Create a Job file in your Project
|
||||
|
||||
This is where you will write your Job code. E.g. `my-job.ts`.
|
||||
|
||||
```ts
|
||||
//this path might be different depending on your project
|
||||
import { client } from "@/trigger";
|
||||
|
||||
client.defineJob({
|
||||
// This is the unique ID for your Job's end-point
|
||||
id: "your-job-id",
|
||||
// This is the name of your Job
|
||||
name: "Your Job name",
|
||||
// This is the version of our SDK you are using
|
||||
version: "0.0.1",
|
||||
...
|
||||
```
|
||||
|
||||
The `id` and `name` are important because they are used to create and identify your Job in the app.
|
||||
|
||||
<Note>
|
||||
This Job must be imported in the `trigger` file in order to be registered when the CLI dev command
|
||||
is run. If you're using Next.js, this can be found in either the `app/api/trigger/route.ts` file
|
||||
for projects using the App Router, or `pages/api/trigger.ts` if you're using the Pages Router.
|
||||
</Note>
|
||||
|
||||
### 2. Choose a Trigger
|
||||
|
||||
This is what kicks-off a Job. There are a few different types of Triggers you can use:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Scheduled">
|
||||
Run a Job on a repeating schedule, using [intervalTrigger](/documentation/concepts/triggers/scheduled#interval)
|
||||
```ts
|
||||
client.defineJob({
|
||||
...
|
||||
trigger: intervalTrigger({
|
||||
seconds: 60,
|
||||
}),
|
||||
...
|
||||
```
|
||||
Or with CRON syntax, using [cronTrigger](/documentation/concepts/triggers/scheduled#using-cron-syntax):
|
||||
```ts
|
||||
client.defineJob({
|
||||
...
|
||||
trigger: cronTrigger({
|
||||
cron: "30 14 * * 1",
|
||||
}),
|
||||
...
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Webhook">
|
||||
|
||||
Start your Jobs when an event happens in another API. You'll need to use [Integrations](/integrations) to do this.
|
||||
|
||||
Here's an example with the GitHub integration.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
...
|
||||
//E.g. When a GitHub issue is modified on the triggerdotdev/trigger.dev repo
|
||||
trigger: github.triggers.repo({
|
||||
event: events.onIssue,
|
||||
owner: "triggerdotdev",
|
||||
repo: "trigger.dev",
|
||||
}),
|
||||
...
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Event">
|
||||
The [eventTrigger](/documentation/concepts/triggers/events) allows you to define an event that your Job listens for.
|
||||
|
||||
When you [send an event](/documentation/concepts/triggers/events#sending-events) with the same name the Job will run.
|
||||
|
||||
``` ts
|
||||
client.defineJob({
|
||||
...
|
||||
//E.g. when a user is created in your app (you send the event)
|
||||
trigger: eventTrigger({
|
||||
name: "user.created",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
paidPlan: z.boolean(),
|
||||
}),
|
||||
...
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Dynamic">
|
||||
These are advanced features that allows you to attach dynamic triggers to a Job. Full information [here](/documentation/concepts/triggers/dynamic).
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 3. Create the Job Tasks
|
||||
|
||||
> A Task is a resumable unit of a Run that can be retried, resumed and is logged.
|
||||
|
||||
<Info>
|
||||
You can use just regular code in your Jobs. But you don't get the benefits of retrying, logging
|
||||
and resumability. More info on [Tasks vs regular
|
||||
code](/documentation/concepts/tasks#tasks-vs-regular-code).
|
||||
</Info>
|
||||
|
||||
You can string together multiple Tasks and regular code in any order you want.
|
||||
|
||||
**Useful built-in Tasks:**
|
||||
|
||||
| Task | Description | Task code |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| [Delay](/documentation/concepts/delays) | Wait for a period of time | `await io.wait("wait", 60);` |
|
||||
| [Log](/sdk/io/logger) | Log a message | `await io.logger.log("Hello");` |
|
||||
| [Send Event](/sdk/io/sendevent) | Send an event (for eventTrigger) | `await io.sendEvent("my-event", { name: "my.event", payload: { hello: "world" } });` |
|
||||
| [Run task](/sdk/io/runtask) | Wrap your own code in this to create a Task | `await io.runTask("My Task", async () => { console.log("Hello"); });` |
|
||||
| [Background fetch](/sdk/io/backgroundfetch) | Fetch data from a URL that can take longer that the serverless timeout. | `await io.backgroundFetch("fetch-some-data", { url: "https://example.com" });` |
|
||||
|
||||
For a full list of built-in Tasks, see the [io SDK reference](/sdk/io).
|
||||
|
||||
**Integration Task examples:**
|
||||
|
||||
<Info>
|
||||
To use our integrations you will need to set them up in the app first. Our guide is
|
||||
[here](/documentation/guides/using-integrations).
|
||||
</Info>
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="OpenAI" description="Generate text completions in a conversational context.">
|
||||
|
||||
**Task:** [backgroundCreateCompletion](/integrations/apis/openai)
|
||||
|
||||
```ts
|
||||
await io.openai.backgroundCreateChatCompletion("background-chat-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Create a good programming joke about background jobs",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
View more OpenAI tasks [here](/integrations/apis/openai).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="GitHub" description="Add a custom label to a GitHub issue.">
|
||||
|
||||
**Task:** [addIssueLabels](/integrations/apis/github-tasks)
|
||||
|
||||
```ts
|
||||
await io.github.addIssueLabels("add label", {
|
||||
owner: payload.repository.owner.login,
|
||||
repo: payload.repository.name,
|
||||
issueNumber: payload.issue.number,
|
||||
labels: ["bug"],
|
||||
});
|
||||
```
|
||||
|
||||
View more GitHub tasks [here](/integrations/apis/github-tasks).
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Resend" description="Send an email using Resend.">
|
||||
|
||||
**Task:** [sendEmail](/integrations/apis/resend)
|
||||
|
||||
```ts
|
||||
await io.resend.sendEmail("send-email", {
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
from: "Trigger.dev <hello@email.trigger.dev>",
|
||||
});
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Slack" description="Post a message to a Slack channel.">
|
||||
|
||||
**Task:** [postMessage](/integrations/apis/slack)
|
||||
|
||||
```ts
|
||||
await io.slack.postMessage("post message", {
|
||||
channel: "C04GWUTDC3W",
|
||||
text: "My first Slack message",
|
||||
});
|
||||
```
|
||||
|
||||
View more Slack tasks [here](/integrations/apis/slack).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
These are just a few examples of Integration Tasks. For many more, browse our [Integrations section](/integrations/).
|
||||
|
||||
### 4. Register your Jobs
|
||||
|
||||
While your app is running, open a **new terminal window or tab** and run:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm dlx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn dlx @trigger.dev/cli@latest dev
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
This will register all of your Jobs, they should appear in your dashboard.
|
||||
|
||||
<Note>
|
||||
Not seeing your Job in the web app? It might be because you forgot to import it. This will need to
|
||||
be either in `app/api/trigger/route.ts` file if you're using the Next,js App Router, or
|
||||
`pages/api/trigger.ts` if you're using the Next,js Pages Router.
|
||||
</Note>
|
||||
|
||||
If you are having trouble getting your job running, please reach out to us and we will help you fix any issues:
|
||||
|
||||
- [Join our Discord](https://discord.gg/kA47vcd8P6)
|
||||
- [Email us](mailto:help@trigger.dev)
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
We recommend exploring all of the below sections to fully understand how to create and run Jobs using Trigger.dev.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Running your jobs"
|
||||
icon="wand-magic-sparkles"
|
||||
href="/documentation/guides/running-jobs"
|
||||
>
|
||||
A guide for how to run your Jobs. Triggering a test Run and Triggering your Job for real
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="Example Jobs" icon="slot-machine" href="/examples">
|
||||
View example Jobs / the example jobs repo. These are a great starting
|
||||
point for creating your own Jobs.
|
||||
</Card>
|
||||
<Card title="SDK reference" icon="code" href="/sdk">
|
||||
How to use the SDK. This includes all the available Tasks, triggers and
|
||||
actions you can use.
|
||||
</Card>
|
||||
|
||||
<Card title="Integrations" icon="grid-2" href="/integrations">
|
||||
Integrations make it easy to authenticate and use APIs.
|
||||
Learn how to use and create integrations.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -17,6 +17,14 @@ View our [guide for writing Jobs](/documentation/guides/create-a-job).
|
||||
|
||||
View our [deployment guide](/documentation/guides/deployment) to learn how to deploy your Jobs.
|
||||
|
||||
### Serverless function timeouts
|
||||
|
||||
If you are deploying your Next.js app to Vercel, you may need to configure a larger max function duration. By default, Vercel has a max function duration of 10 seconds. As outlined in our [Limits docs](/documentation/concepts/limits#maximum-task-duration), the max function duration effects the maximum [Task](/documentation/concepts/tasks) duration.
|
||||
|
||||
So if you have any tasks that may take longer than 10 seconds to run (or close to 10 seconds), you should increase the max function duration to a higher value (only available to paid Vercel plans).
|
||||
|
||||
See the [Vercel docs](https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration) for more information on increasing the `maxDuration` for your Vercel Serverless Functions.
|
||||
|
||||
## Middleware
|
||||
|
||||
Next.js Middleware allows you to run code before a request is completed, and if you are using it currently in your Next.js project (or you add it later), you might need to guard against altering requests to the `/api/trigger` endpoint, which needs to be exposed to the Trigger.dev installation (either your self-hosted one or the Trigger.dev Cloud).
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
title: "Writing Jobs - Step by Step"
|
||||
description: "Best practices for writing well-behaving Jobs in your codebase"
|
||||
---
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
This guide assumes you already have a project setup and working with Trigger.dev. If not, head over to our [Quick Start guides](/documentation/quickstarts/introduction) to get up and running in a few minutes.
|
||||
|
||||
## 1. Define your Job
|
||||
|
||||
A Job is a collection of Tasks that are run in a specific order. You can think of it as a function that you can run on a schedule, or when an event happens. Jobs are defined by calling the `TriggerClient.defineJob` function
|
||||
|
||||
```ts
|
||||
//this path might be different depending on your project
|
||||
import { client } from "@/trigger";
|
||||
|
||||
client.defineJob({
|
||||
// ... job definition
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you aren't seeing defined jobs in your Trigger.dev dashboard, it might be because the job file
|
||||
isn't being imported in your app.
|
||||
</Note>
|
||||
|
||||
## 2. Choose a name and ID
|
||||
|
||||
Each job must have a unique and stable `id` and `name`. The `id` is used to identify the Job in the database, and the `name` is used to identify the Job in the UI.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
// ... job definition
|
||||
});
|
||||
```
|
||||
|
||||
We will pass the value of the `id` property through a slugifier because we use it in URLs in our Dashboard. This means you can use any characters you want, but we recommend using only lowercase letters, numbers and dashes.
|
||||
|
||||
## 3. Set the current version
|
||||
|
||||
The `version` property is used to track changes to your Job. It's required to be a [semantic version](https://semver.org/) string. You can track changes to your Job by incrementing the version number, and we will display in the Dashboard which version each Job Run was created with.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
// ... job definition
|
||||
});
|
||||
```
|
||||
|
||||
## 4. Choose a Trigger
|
||||
|
||||
The trigger you choose determines how and when a job will run. See our [Triggers guide](/documentation/concepts/triggers/introduction) for more information. The Trigger you choose also defines the type of the run `payload` argument (more in this below)
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "my.event",
|
||||
}),
|
||||
// ... job definition
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Add integrations
|
||||
|
||||
Integrations provide a convienent way to create and run tasks against authenticated APIs inside your Job's run function. You'll need to pass them in the `integrations` option when defining your Job.
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
|
||||
const slack = new Slack({ id: "slack" });
|
||||
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "my.event",
|
||||
}),
|
||||
integrations: { slack },
|
||||
// ... job definition
|
||||
});
|
||||
```
|
||||
|
||||
## 6. Implement the run function
|
||||
|
||||
The `run` function implements your custom code that will be executed when your Job is run. It's an async function that takes three arguments:
|
||||
|
||||
- The run `payload` - The type of the `payload` argument is determined by the Trigger you choose.
|
||||
- An instance of `IO`, which exposes built-in tasks and allows you to create your own, as well as interact with integrations.
|
||||
- A `context` object, which contains information about the current run, such as the run ID, the Job ID, and the Job version. [Context reference](/sdk/context)
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
|
||||
const slack = new Slack({ id: "slack" });
|
||||
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "my.event",
|
||||
}),
|
||||
integrations: { slack },
|
||||
run: async (payload, io, context) => {
|
||||
// ... your code
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
We do not compile and ship your code to run on the Trigger.dev server. It runs exactly where
|
||||
you've deployed your code (e.g. Vercel).
|
||||
</Note>
|
||||
|
||||
The `run` function you define is like a normal JavaScript function in all respects except one: it will be called one or more times to complete a single Job Run.
|
||||
|
||||
This means that you can't rely on any state that is not persisted between runs, and you must **create tasks** to ensure that work is not repeated.
|
||||
|
||||
<Accordion title="Why does the run function work like this?">
|
||||
The run function is called multiple times to ensure that your Job is resilient to failure and can finish running even if it is interrupted. There are many reasons why a run could be interrupted and resumed later, including:
|
||||
|
||||
- The serverless function times out
|
||||
- A task fails and needs to be retried
|
||||
- A wait task is used to delay continuing the run until a later time
|
||||
- A run yields execution to prevent the serverless function from timing out
|
||||
- Waiting for a [backgroundFetch](/sdk/io/backgroundfetch) to complete
|
||||
- Waiting for a task callback to be called (like the ones used in our [Replicate integration](/integrations/apis/replicate#predictions))
|
||||
- Waiting for [another event](https://github.com/triggerdotdev/trigger.dev/issues/472) to fire.
|
||||
|
||||
</Accordion>
|
||||
|
||||
Tasks are so important that we've dedicated a whole section to them. See our [Tasks guide](/documentation/concepts/tasks) for more information.
|
||||
|
||||
### Built in tasks
|
||||
|
||||
We provide some built-in tasks that you can use in your run function:
|
||||
|
||||
| Task | Description | Task code |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| [Delay](/documentation/concepts/delays) | Wait for a period of time | `await io.wait("wait", 60);` |
|
||||
| [Log](/sdk/io/logger) | Log a message | `await io.logger.log("Hello");` |
|
||||
| [Send Event](/sdk/io/sendevent) | Send an event (for eventTrigger) | `await io.sendEvent("my-event", { name: "my.event", payload: { hello: "world" } });` |
|
||||
| [Background fetch](/sdk/io/backgroundfetch) | Fetch data from a URL that can take longer that the serverless timeout. | `await io.backgroundFetch("fetch-some-data", { url: "https://example.com" });` |
|
||||
|
||||
For a full list of built-in Tasks, see the [io SDK reference](/sdk/io). The below example makes use a few of these built-in tasks:
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
|
||||
const slack = new Slack({ id: "slack" });
|
||||
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "my.event",
|
||||
}),
|
||||
integrations: { slack },
|
||||
run: async (payload, io, context) => {
|
||||
await io.logger.info("Received the my.event event", { payload });
|
||||
await io.sendEvent("send-event", {
|
||||
name: "other.event",
|
||||
payload: { hello: "world" },
|
||||
});
|
||||
|
||||
await io.wait("wait for 60 seconds", 60);
|
||||
|
||||
await io.backgroundFetch("fetch-some-data", {
|
||||
url: "https://example.com",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Create your own tasks
|
||||
|
||||
You can also create your own tasks or use tasks provided by our integration packages. See [Creating Tasks](/documentation/concepts/tasks#creating-tasks) for more information. The example below demonstrates creating tasks in 3 different ways:
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
|
||||
const slack = new Slack({ id: "slack" });
|
||||
|
||||
client.defineJob({
|
||||
id: "my-job",
|
||||
name: "My Job",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "my.event",
|
||||
}),
|
||||
integrations: { slack },
|
||||
run: async (payload, io, context) => {
|
||||
// Use runTask with the "get-user" cacheKey, and return the user
|
||||
const user = await io.runTask("get-user", async () => {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: payload.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Use the Slack integration to create a task using the "post-message" cacheKey
|
||||
const message = await io.slack.postMessage("post-message", {
|
||||
channel: process.env.SLACK_CHANNEL_ID,
|
||||
message: `Hello ${user.name}`,
|
||||
});
|
||||
|
||||
await io.wait("wait for 10 seconds", 10);
|
||||
|
||||
// Use the Slack integration's runTask method to add a reaction to the message
|
||||
await io.slack.runTask("add-reaction", async (client) => {
|
||||
// client here is an authenticated instance of the Slack SDK
|
||||
await client.reactions.add({
|
||||
channel: process.env.SLACK_CHANNEL_ID,
|
||||
name: "thumbsup",
|
||||
timestamp: message.ts,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Handling errors
|
||||
|
||||
If your run function throws an error, the Job Run will fail and the error will be displayed in the Dashboard. If you'd like to retry on an error, you can do so using tasks and the `retry` option.
|
||||
|
||||
```ts
|
||||
const user = await io.runTask(
|
||||
"get-user",
|
||||
async () => {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: payload.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
retry: {
|
||||
limit: 3,
|
||||
factor: 2,
|
||||
minTimeoutInMs: 1000,
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
See the [retry options](/sdk/io/runtask) for more information.
|
||||
|
||||
## 8. Skip catching internal errors
|
||||
|
||||
We will throw some errors internally to interrupt run execution so they can be resumed later. If you put a `try/catch` block in your run code and catch these errors, your job will not work correctly. You can check if an error is an internal error using [isTriggerError()](/sdk/istriggererror):
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
run: async (payload, io, context) => {
|
||||
try {
|
||||
// Use runTask with the "get-user" cacheKey, and return the user
|
||||
const user = await io.runTask("get-user", async () => {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: payload.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTriggerError(error)) throw error;
|
||||
|
||||
// do something with your error here
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, you can use the [io.try()](/sdk/io/try) function:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
run: async (payload, io, context) => {
|
||||
const result = io.try(
|
||||
() => {
|
||||
return io.runTask("get-user", async () => {
|
||||
return prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: payload.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
async (error) => {
|
||||
//you can return data from the error handler,
|
||||
//if you wish to elegantly deal with errors
|
||||
return {
|
||||
success: false as const,
|
||||
error,
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 9. Creating tasks in a loop
|
||||
|
||||
If you want to create tasks in a loop, you should use `for const ... of` to ensure that the tasks are created in the correct order.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
run: async (payload, io, context) => {
|
||||
for (const user of payload.users) {
|
||||
await io.runTask(`update-user-${user.id}`, async () => {
|
||||
return prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
We don't currently support running tasks in parallel so `Promise.all` will not work correctly.
|
||||
This is something on our roadmap that we hope to support soon.
|
||||
</Warning>
|
||||
|
||||
## 10. Return data from your Job
|
||||
|
||||
Anything you return from the `run` function will be automatically set as the run output and displayed in the Dashboard.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
run: async (payload, io, context) => {
|
||||
return {
|
||||
success: true,
|
||||
message: "Hello world",
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
We recommend exploring all of the below sections to fully understand how to create and run Jobs using Trigger.dev.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Running your jobs"
|
||||
icon="wand-magic-sparkles"
|
||||
href="/documentation/guides/running-jobs"
|
||||
>
|
||||
A guide for how to run your Jobs. Triggering a test Run and Triggering your Job for real
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="Example Jobs" icon="slot-machine" href="/examples">
|
||||
View example Jobs / the example jobs repo. These are a great starting
|
||||
point for creating your own Jobs.
|
||||
</Card>
|
||||
<Card title="SDK reference" icon="code" href="/sdk">
|
||||
How to use the SDK. This includes all the available Tasks, triggers and
|
||||
actions you can use.
|
||||
</Card>
|
||||
|
||||
<Card title="Integrations" icon="grid-2" href="/integrations">
|
||||
Integrations make it easy to authenticate and use APIs.
|
||||
Learn how to use and create integrations.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -53,16 +53,18 @@ We'd love to hear from you or give you a hand getting started. Here are some way
|
||||
Trigger.dev.
|
||||
</Card>
|
||||
<Card
|
||||
title="Follow us on Twitter"
|
||||
icon="twitter"
|
||||
title="Follow us on X (Twitter)"
|
||||
icon={<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 0 512 512"><path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"/></svg>
|
||||
}
|
||||
href="https://twitter.com/triggerdotdev"
|
||||
color="#1DA1F2"
|
||||
>
|
||||
Follow us on Twitter to get the latest updates and news.
|
||||
Follow us on X (Twitter) to get the latest updates and news.
|
||||
</Card>
|
||||
<Card
|
||||
title="Schedule a call"
|
||||
icon="phone"
|
||||
iconType="solid"
|
||||
href="https://cal.com/team/triggerdotdev/founders-call"
|
||||
>
|
||||
Arrange a call with one of the founders. We can help answer questions, build API Integrations
|
||||
@@ -71,6 +73,7 @@ We'd love to hear from you or give you a hand getting started. Here are some way
|
||||
<Card
|
||||
title="Give us a star on GitHub"
|
||||
icon="star"
|
||||
iconType="solid"
|
||||
href="https://github.com/triggerdotdev/trigger.dev"
|
||||
color="#fbbf24"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Astro Quick Start"
|
||||
sidebarTitle: "Astro"
|
||||
description: "Start creating Jobs in 5 minutes in your Astro project."
|
||||
icon: "rocket"
|
||||
---
|
||||
|
||||
This quick start guide will get you up and running with Trigger.dev.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Express Quick Start"
|
||||
sidebarTitle: "Express"
|
||||
description: "Start creating Jobs in 5 minutes in your Express project."
|
||||
icon: "node-js"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-express.mdx" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Fastify Quick Start"
|
||||
sidebarTitle: "Fastify"
|
||||
description: "Start creating Jobs in 5 minutes in your Fastify project."
|
||||
icon: "paw-claws"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-fastify.mdx" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "NestJS Quick Start"
|
||||
sidebarTitle: "NestJS"
|
||||
description: "Start creating Jobs in 5 minutes in your NestJS project."
|
||||
icon: "paw-claws"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-nestjs.mdx" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Next.js Quick Start"
|
||||
sidebarTitle: "Next.js"
|
||||
description: "Start creating Jobs in 5 minutes in your Next.js project."
|
||||
icon: "N"
|
||||
---
|
||||
|
||||
This quick start guide will get you up and running with Trigger.dev.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Nuxt Quick Start"
|
||||
sidebarTitle: "Nuxt"
|
||||
description: "Start creating Jobs in 5 minutes in your Nuxt project."
|
||||
icon: "triangle"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-nuxt.mdx" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Redwood Quick Start"
|
||||
sidebarTitle: "Redwood"
|
||||
description: "Start creating Jobs in 5 minutes in your Redwood project."
|
||||
icon: "js"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-redwood.mdx" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Remix Quick Start"
|
||||
sidebarTitle: "Remix"
|
||||
description: "Start creating Jobs in 5 minutes in your Remix project."
|
||||
icon: "r"
|
||||
---
|
||||
|
||||
This quick start guide will get you up and running with Trigger.dev.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "SvelteKit Quick Start"
|
||||
sidebarTitle: "SvelteKit"
|
||||
description: "Start creating Jobs in 5 minutes in your SvelteKit project."
|
||||
icon: "square-s"
|
||||
---
|
||||
|
||||
<Snippet file="manual-setup-sveltekit.mdx" />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Airtable tasks
|
||||
sidebarTitle: Tasks
|
||||
---
|
||||
|
||||
Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want.
|
||||
|
||||
---
|
||||
|
||||
## All tasks
|
||||
|
||||
### `base.table.getRecords`
|
||||
|
||||
Gets multiple records for a table. You can filter. [Official Airtable Docs](https://airtable.com/developers/web/api/update-multiple-records).
|
||||
|
||||
```ts example.ts
|
||||
// Gets multiple records from the table.
|
||||
// Here we only get the Status fields (columns)
|
||||
await table.getRecords("multiple records", {
|
||||
fields: ["Status"],
|
||||
});
|
||||
```
|
||||
|
||||
### `base.table.getRecord`
|
||||
|
||||
Gets a single record (using the id) for a table. [Official Airtable Docs](https://airtable.com/developers/web/api/get-record).
|
||||
|
||||
```ts example.ts
|
||||
// Get a single record using the ID of the first record in the fetched records.
|
||||
await table.getRecord("single", records[0].id);
|
||||
```
|
||||
|
||||
### `base.table.createRecords`
|
||||
|
||||
Create one or more records in a table. [Official Airtable Docs](https://airtable.com/developers/web/api/create-records).
|
||||
|
||||
```ts example.ts
|
||||
// Create newRecords by adding a record to the table.
|
||||
await table.createRecords("create records", [
|
||||
{
|
||||
// Define the new record to be created
|
||||
// with field values for "Launch goals" and "Status."
|
||||
fields: {
|
||||
"Launch goals": "Created from Trigger.dev",
|
||||
Status: "In progress",
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### `base.table.updateRecords`
|
||||
|
||||
Update one or more records in a table. [Official Airtable Docs](https://airtable.com/developers/web/api/update-record).
|
||||
|
||||
```ts example.ts
|
||||
// Update the record that was just created.
|
||||
await table.updateRecords(
|
||||
// Specify the table's name as "update records."
|
||||
"update records",
|
||||
// Use the `map` method to create an array of records to be updated.
|
||||
newRecords.map((record) => ({
|
||||
// For each record, specify the: ID and the field to update ("Status" to "At risk").
|
||||
id: record.id,
|
||||
fields: {
|
||||
Status: "At risk",
|
||||
},
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
### `base.table.deleteRecords`
|
||||
|
||||
Delete one or more records in a table (using ids). [Official Airtable Docs](https://airtable.com/developers/web/api/delete-record).
|
||||
|
||||
```ts example.ts
|
||||
// Delete records from the table.
|
||||
await table.deleteRecords(
|
||||
// Specify the table's name as "delete records."
|
||||
"delete records"
|
||||
);
|
||||
```
|
||||
|
||||
### `runTask`
|
||||
|
||||
Do anything that’s possible with the official Airtable Node SDK.
|
||||
|
||||
## Example Usage
|
||||
|
||||
You can optionally add the types for your Table – this gives you nice type inference and errors when writing your code.
|
||||
|
||||
```ts
|
||||
// This is the type definition for my Table
|
||||
type LaunchGoalsAndOkRs = {
|
||||
"Launch goals"?: string;
|
||||
DRI?: Collaborator;
|
||||
Team?: string;
|
||||
Status?: "On track" | "In progress" | "At risk";
|
||||
"Key results"?: Array<string>;
|
||||
"Features (from 💻 Features table)"?: Array<string>;
|
||||
"Status (from 💻 Features)": Array<
|
||||
"Live" | "Complete" | "In progress" | "Planning" | "In reviews"
|
||||
>;
|
||||
};
|
||||
|
||||
client.defineJob({
|
||||
id: "airtable-example",
|
||||
name: "Airtable Example: getRecords",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({
|
||||
name: "airtable.example",
|
||||
}),
|
||||
integrations: {
|
||||
// Make sure to add the integration here
|
||||
airtable,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
// Adding the type to table<YourTableType>("<your table name>") gives you nice type inference and errors
|
||||
// You can leave it out as well table("<your table name>")
|
||||
const table = io.airtable.base("<your base id>").table<LaunchGoalsAndOkRs>("<your table name>");
|
||||
|
||||
// Gets multiple records from the table. Here we only get the Status fields (columns)
|
||||
const records = await table.getRecords("multiple records", {
|
||||
fields: ["Status"],
|
||||
});
|
||||
await io.logger.log(records[0].fields.Status ?? "no status");
|
||||
|
||||
// Get a single record
|
||||
const aRecord = await table.getRecord("single", records[0].id);
|
||||
|
||||
// Create a new record
|
||||
const newRecords = await table.createRecords("create records", [
|
||||
{
|
||||
fields: {
|
||||
"Launch goals": "Created from Trigger.dev",
|
||||
Status: "In progress",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Update the record we just created
|
||||
const updatedRecords = await table.updateRecords(
|
||||
"update records",
|
||||
newRecords.map((record) => ({
|
||||
id: record.id,
|
||||
fields: { Status: "At risk" },
|
||||
}))
|
||||
);
|
||||
|
||||
// Wait for 5 seconds
|
||||
await io.wait("5 secs", 5);
|
||||
|
||||
// Delete the record we just created + updated
|
||||
const deletedRecords = await table.deleteRecords(
|
||||
"delete records",
|
||||
updatedRecords.map((record) => record.id)
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -1,12 +1,21 @@
|
||||
---
|
||||
title: Airtable
|
||||
title: Airtable overview & authentication
|
||||
sidebarTitle: Overview & authentication
|
||||
---
|
||||
|
||||
Use the Airtable integration to create, read, update, and delete records in your Airtable bases.
|
||||
## Overview
|
||||
|
||||
Coming soon: subscribing to changes in your Airtable bases using triggers.
|
||||
Our Airtable integration allows you to easily connect to the Airtable API and perform tasks such as creating / updating / deleting single or multiple records in your tables.
|
||||
|
||||
<Snippet file="integration-getting-started.mdx" />
|
||||
For examples of some of the things you can do with it, check out our Jobs Showcase:
|
||||
|
||||
<Card
|
||||
title="Jobs Showcase - Airtable"
|
||||
icon="rocket"
|
||||
href="https://trigger.dev/showcase?tags=&integrations=airtable"
|
||||
>
|
||||
Check out pre-built Airtable jobs in our showcase.
|
||||
</Card>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -35,6 +44,8 @@ To use the Airtable API with Trigger.dev, you can either use OAuth or a Personal
|
||||
|
||||
### OAuth
|
||||
|
||||
To use OAuth you can connect to Airtable via the Trigger.dev [web app](https://cloud.trigger.dev). Click 'Integrations' in the side panel of any project, and configure Airtable with the ID you want to use in your job and the required [scopes](https://airtable.com/developers/web/api/scopes).
|
||||
|
||||
```ts
|
||||
import { Airtable } from "@trigger.dev/airtable";
|
||||
|
||||
@@ -58,84 +69,12 @@ const airtable = new Airtable({
|
||||
});
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Include the Airtable integration in your Trigger.dev job.
|
||||
|
||||
You can optionally add the types for your Table – this gives you nice type inference and errors when writing your code.
|
||||
|
||||
```ts
|
||||
//this is the type definition for my Table
|
||||
type LaunchGoalsAndOkRs = {
|
||||
"Launch goals"?: string;
|
||||
DRI?: Collaborator;
|
||||
Team?: string;
|
||||
Status?: "On track" | "In progress" | "At risk";
|
||||
"Key results"?: Array<string>;
|
||||
"Features (from 💻 Features table)"?: Array<string>;
|
||||
"Status (from 💻 Features)": Array<
|
||||
"Live" | "Complete" | "In progress" | "Planning" | "In reviews"
|
||||
>;
|
||||
};
|
||||
|
||||
client.defineJob({
|
||||
id: "airtable-example-1",
|
||||
name: "Airtable Example 1: getRecords",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({
|
||||
name: "airtable.example",
|
||||
}),
|
||||
integrations: {
|
||||
//make sure to add the integration here
|
||||
airtable,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
//adding the type to table<YourTableType>("<your table name>") gives you nice type inference and errors
|
||||
//you can leave it out as well table("<your table name>")
|
||||
const table = io.airtable.base("<your base id>").table<LaunchGoalsAndOkRs>("<your table name>");
|
||||
|
||||
//gets multiple records from the table. Here we only get the Status fields (columns)
|
||||
const records = await table.getRecords("muliple records", { fields: ["Status"] });
|
||||
await io.logger.log(records[0].fields.Status ?? "no status");
|
||||
|
||||
//Get a single record
|
||||
const aRecord = await table.getRecord("single", records[0].id);
|
||||
|
||||
//create a new record
|
||||
const newRecords = await table.createRecords("create records", [
|
||||
{
|
||||
fields: { "Launch goals": "Created from Trigger.dev", Status: "In progress" },
|
||||
},
|
||||
]);
|
||||
|
||||
//update the record we just created
|
||||
const updatedRecords = await table.updateRecords(
|
||||
"update records",
|
||||
newRecords.map((record) => ({
|
||||
id: record.id,
|
||||
fields: { Status: "At risk" },
|
||||
}))
|
||||
);
|
||||
|
||||
//wait 5 seconds
|
||||
await io.wait("5 secs", 5);
|
||||
|
||||
//delete the record we just created + updated
|
||||
const deletedRecords = await table.deleteRecords(
|
||||
"delete records",
|
||||
updatedRecords.map((record) => record.id)
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
| Function Name | Description |
|
||||
| -------------------------- | --------------------------------------------------------------- |
|
||||
| `base.table.getRecords` | Gets multiple records for a table. You can filter |
|
||||
| `base.table.getRecord` | Gets a single record (using the id) for a table. |
|
||||
| `base.table.createRecords` | Create one or more records in a table. |
|
||||
| `base.table.updateRecords` | Update one or more records in a table. |
|
||||
| `base.table.deleteRecords` | Delete one or more records in a table (using ids). |
|
||||
| `runTask` | Do anything that's possible with the official Airtable Node SDK |
|
||||
Once you have set up a Airtable client, you can use it to create tasks.
|
||||
|
||||
<CardGroup>
|
||||
<Card title="Tasks" icon="sparkles" href="/integrations/apis/airtable-tasks">
|
||||
Perform tasks such as creating / updating / deleting single or multiple records in table.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -259,5 +259,3 @@ client.defineJob({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make sure to pass the `idempotencyKey` to the underlying client to ensure that the API call is only executed once. This is only needed for mutating API calls.
|
||||
|
||||
@@ -5,7 +5,11 @@ sidebarTitle: Overview & authentication
|
||||
|
||||
## Overview
|
||||
|
||||
Our GitHub integration allows you to create triggers and tasks that interact with GitHub. For examples of some of the things you can do with it, check out our Jobs Showcase:
|
||||
Our GitHub integration allows you to create triggers and tasks that interact with GitHub.
|
||||
|
||||
Trigger jobs when events happen, such as when a new issue is added to a repo, a commit is pushed, or a pull request is opened. You can also use the integration to perform tasks such as creating issues, getting information about a repo, adding comments, and much more.
|
||||
|
||||
For examples of some of the things you can do with it, check out our Jobs Showcase:
|
||||
|
||||
<Card
|
||||
title="Jobs Showcase - GitHub"
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
---
|
||||
title: OpenAI tasks
|
||||
sidebarTitle: Tasks
|
||||
---
|
||||
|
||||
Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want.
|
||||
|
||||
---
|
||||
|
||||
## All tasks
|
||||
|
||||
### `createCompletion`
|
||||
|
||||
Generates text completions as per given prompt. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/chat)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code demonstrates using OpenAI's text completion with the "davinci" model.
|
||||
// It generates text based on the given prompt.
|
||||
await io.openai.createCompletion("completion", {
|
||||
model: "davinci",
|
||||
prompt: "Once upon a time",
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
### `backgroundCreateCompletion`
|
||||
|
||||
Generates text completions in the background. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/chat)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code showcases background text completion using the "gpt-3.5-turbo" model.
|
||||
// It generates text based on the provided programming task and logs the result.
|
||||
const programmingTask = `Create a function that checks if a string is a palindrome.`;
|
||||
|
||||
const response = await io.openai.backgroundCreateCompletion("background-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
prompt: `Coding task: ${programmingTask}\n\n`,
|
||||
});
|
||||
|
||||
await io.logger.info("codeSnippet", response.choices[0]?.text);
|
||||
},
|
||||
```
|
||||
|
||||
### `createChatCompletion`
|
||||
|
||||
Generates text completions in a conversational context. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/chat/create)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code demonstrates chat completion with the "gpt-3.5-turbo" model.
|
||||
// It simulates a conversation by providing messages and receiving a chat response.
|
||||
await io.openai.createChatCompletion("chat-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Create a good programming joke about background jobs",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
### `backgroundCreateChatCompletion`
|
||||
|
||||
Generates text completions in a conversational context in the background. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/chat/object)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code showcases background chat completion using the "gpt-3.5-turbo" model.
|
||||
// It simulates a conversation with a user message and logs the response choices.
|
||||
const response = await io.openai.backgroundCreateChatCompletion("background-chat-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Create a good programming joke about background jobs",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await io.logger.info("choices", response.choices);
|
||||
},
|
||||
```
|
||||
|
||||
### `retrieveModel`
|
||||
|
||||
Retrieves a specific model by ID. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/models/retrieve)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// In this code snippet, we retrieve detailed information about a specific OpenAI model.
|
||||
|
||||
// Specify the ID of the model you want to retrieve. Replace 'your_model_id' with the actual model ID.
|
||||
const modelIdToRetrieve = "your_model_id";
|
||||
|
||||
try {
|
||||
// Retrieve the model information using the OpenAI API
|
||||
const retrievedModel = await io.openai.retrieveModel("get-model", {
|
||||
model: modelIdToRetrieve,
|
||||
});
|
||||
|
||||
// Log the detailed model information
|
||||
await io.logger.info("retrievedModel", retrievedModel);
|
||||
} catch (error) {
|
||||
// Handle errors, such as if the model with the provided ID does not exist.
|
||||
await io.logger.error("Error retrieving model:", error.message);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### `listModels`
|
||||
|
||||
Lists the available models. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/models/list)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code lists available models without retrieving detailed information.
|
||||
const models = await io.openai.listModels("list-models");
|
||||
},
|
||||
```
|
||||
|
||||
### `createEdit`
|
||||
|
||||
Edits a given text prompt. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/edits/create)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code snippet demonstrates using the OpenAI API to create an edit task.
|
||||
|
||||
// Specify the task parameters:
|
||||
const editTaskParams = {
|
||||
model: "text-davinci-edit-001", // Replace with the desired model
|
||||
input: "Thsi is ridddled with erors", // Replace with the input text
|
||||
instruction: "Fix the spelling errors", // Replace with the editing instruction
|
||||
};
|
||||
|
||||
try {
|
||||
// Create an edit task using the OpenAI API
|
||||
const editResponse = await io.openai.createEdit("edit", editTaskParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("editResponse", editResponse);
|
||||
} catch (error) {
|
||||
// Handle any potential errors that may occur during the API request.
|
||||
await io.logger.error("Error creating edit task:", error.message);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### `createImage`
|
||||
|
||||
Generates images from textual descriptions. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/images/create)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
const imageResults = await io.openai.createImage("image", {
|
||||
prompt: "A hedgehog wearing a party hat",
|
||||
n: 2,
|
||||
size: "256x256",
|
||||
response_format: "url",
|
||||
});
|
||||
```
|
||||
|
||||
### `createImageEdit`
|
||||
|
||||
Creates an edited or extended image given an original image and a prompt. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/images/createEdit)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for the image edit
|
||||
const imageEditParams = {
|
||||
style: "data:image/png;base64,base64_encoded_style_image",
|
||||
content: "data:image/png;base64,base64_encoded_content_image",
|
||||
};
|
||||
|
||||
// Create the image edit using the OpenAI API
|
||||
const imageEditResponse = await io.openai.createImageEdit(imageEditParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("imageEditResponse", imageEditResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `createImageVariation`
|
||||
|
||||
Creates a variation of a given image. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/images/createVariation)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for creating an image variation
|
||||
const imageVariationParams = {
|
||||
image: "data:image/png;base64,base64_encoded_image",
|
||||
variation: "brightness(1.2) contrast(0.8) rotate(45deg)",
|
||||
};
|
||||
|
||||
// Create the image variation using the OpenAI API
|
||||
const imageVariationResponse = await io.openai.createImageVariation(imageVariationParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("imageVariationResponse", imageVariationResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `createEmbedding`
|
||||
|
||||
Generates embeddings for a given text. [Official OpenAI Docs](hhttps://platform.openai.com/docs/api-reference/embeddings/object)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// This code snippet demonstrates using the OpenAI API to create a text embedding.
|
||||
|
||||
// Specify the task parameters:
|
||||
const embeddingTaskParams = {
|
||||
model: "text-embedding-ada-002", // Replace with the desired model
|
||||
input: "The food was delicious and the waiter...", // Replace with the input text
|
||||
};
|
||||
|
||||
try {
|
||||
// Create a text embedding using the OpenAI API
|
||||
const embeddingResponse = await io.openai.createEmbedding("embedding", embeddingTaskParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("embeddingResponse", embeddingResponse);
|
||||
} catch (error) {
|
||||
// Handle any potential errors that may occur during the API request.
|
||||
await io.logger.error("Error creating text embedding:", error.message);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### `createFile`
|
||||
|
||||
Uploads a file to the OpenAI API. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/files/object)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for creating a file
|
||||
const fileParams = {
|
||||
name: "example.txt",
|
||||
content: "This is the content of the file.",
|
||||
};
|
||||
|
||||
// Create the file using the OpenAI API
|
||||
const fileResponse = await io.openai.createFile(fileParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("fileResponse", fileResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `listFiles`
|
||||
|
||||
Lists the uploaded files. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/files/list)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// List the files available in your OpenAI account
|
||||
const fileListResponse = await io.openai.listFiles();
|
||||
|
||||
// Log the list of files
|
||||
await io.logger.info("fileListResponse", fileListResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `createFineTuneFile`
|
||||
|
||||
Uploads a file for fine-tuning a model. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for creating a fine-tune file
|
||||
const fineTuneFileParams = {
|
||||
model: "text-davinci-002",
|
||||
prompt: "Translate English to French: 'Hello, world.'",
|
||||
language: "en",
|
||||
description: "Fine-tune file for translation task",
|
||||
};
|
||||
|
||||
// Create the fine-tune file using the OpenAI API
|
||||
const fineTuneFileResponse = await io.openai.createFineTuneFile(fineTuneFileParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("fineTuneFileResponse", fineTuneFileResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `createFineTune`
|
||||
|
||||
Fine-tunes a model on a given task. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/create)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for creating a fine-tune task
|
||||
const fineTuneParams = {
|
||||
model: "text-davinci-002",
|
||||
dataset: "your_dataset_id",
|
||||
description: "Fine-tune task for custom dataset",
|
||||
};
|
||||
|
||||
// Create the fine-tune task using the OpenAI API
|
||||
const fineTuneResponse = await io.openai.createFineTune(fineTuneParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("fineTuneResponse", fineTuneResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `listFineTunes`
|
||||
|
||||
Lists the available fine-tunes. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/list)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// List the fine-tunes available in your OpenAI account
|
||||
const fineTunesListResponse = await io.openai.listFineTunes();
|
||||
|
||||
// Log the list of fine-tunes
|
||||
await io.logger.info("fineTunesListResponse", fineTunesListResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `retrieveFineTune`
|
||||
|
||||
Retrieves a specific fine-tune by ID. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/retrieve)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the ID of the fine-tune you want to retrieve
|
||||
const fineTuneId = "your_fine_tune_id"; // Replace with the actual fine-tune ID
|
||||
|
||||
// Retrieve the fine-tune using the OpenAI API
|
||||
const retrievedFineTune = await io.openai.retrieveFineTune(fineTuneId);
|
||||
|
||||
// Log the retrieved fine-tune
|
||||
await io.logger.info("retrievedFineTune", retrievedFineTune);
|
||||
},
|
||||
```
|
||||
|
||||
### `cancelFineTune`
|
||||
|
||||
Cancels a specific fine-tune by ID. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/cancel)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the ID of the fine-tune you want to cancel
|
||||
const fineTuneIdToCancel = "your_fine_tune_id"; // Replace with the actual fine-tune ID
|
||||
|
||||
// Cancel the specified fine-tune using the OpenAI API
|
||||
const cancellationResponse = await io.openai.cancelFineTune(fineTuneIdToCancel);
|
||||
|
||||
// Log the cancellation response
|
||||
await io.logger.info("cancellationResponse", cancellationResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `createFineTuningJob`
|
||||
|
||||
Creates a job that fine-tunes a specified model from a given dataset. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/create)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the parameters for creating a fine-tuning job
|
||||
const fineTuningJobParams = {
|
||||
fineTuneId: "your_fine_tune_id", // Replace with the actual fine-tune ID
|
||||
datasetId: "your_dataset_id", // Replace with the ID of your dataset
|
||||
model: "text-davinci-002", // Replace with the model for fine-tuning
|
||||
n_examples: 100, // Replace with the number of examples
|
||||
};
|
||||
|
||||
// Create the fine-tuning job using the OpenAI API
|
||||
const fineTuningJobResponse = await io.openai.createFineTuningJob(fineTuningJobParams);
|
||||
|
||||
// Log the response
|
||||
await io.logger.info("fineTuningJobResponse", fineTuningJobResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `retrieveFineTuningJob`
|
||||
|
||||
Get info about a fine-tuning job. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/retrieve)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the ID of the fine-tuning job you want to retrieve
|
||||
const fineTuningJobId = "your_fine_tuning_job_id"; // Replace with the actual job ID
|
||||
|
||||
// Retrieve the fine-tuning job using the OpenAI API
|
||||
const retrievedJob = await io.openai.retrieveFineTuningJob(fineTuningJobId);
|
||||
|
||||
// Log the retrieved job
|
||||
await io.logger.info("retrievedJob", retrievedJob);
|
||||
},
|
||||
```
|
||||
|
||||
### `cancelFineTuningJob`
|
||||
|
||||
Cancel a fine-tuning job. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/cancel)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the ID of the fine-tuning job you want to cancel
|
||||
const fineTuningJobIdToCancel = "your_fine_tuning_job_id"; // Replace with the actual job ID
|
||||
|
||||
// Cancel the specified fine-tuning job using the OpenAI API
|
||||
const cancellationResponse = await io.openai.cancelFineTuningJob(fineTuningJobIdToCancel);
|
||||
|
||||
// Log the cancellation response
|
||||
await io.logger.info("cancellationResponse", cancellationResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `listFineTuningJobEvents`
|
||||
|
||||
List events for a fine-tuning job. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/list-events)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Specify the ID of the fine-tuning job for which you want to list events
|
||||
const fineTuningJobId = "your_fine_tuning_job_id"; // Replace with the actual job ID
|
||||
|
||||
// List events for the specified fine-tuning job using the OpenAI API
|
||||
const eventsListResponse = await io.openai.listFineTuningJobEvents(fineTuningJobId);
|
||||
|
||||
// Log the list of events
|
||||
await io.logger.info("eventsListResponse", eventsListResponse);
|
||||
},
|
||||
```
|
||||
|
||||
### `listFineTuningJobs`
|
||||
|
||||
List fine tuning jobs. [Official OpenAI Docs](https://platform.openai.com/docs/api-reference/fine-tuning/list)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// List the fine-tuning jobs available in your OpenAI account
|
||||
const jobsListResponse = await io.openai.listFineTuningJobs();
|
||||
|
||||
// Log the list of fine-tuning jobs
|
||||
await io.logger.info("jobsListResponse", jobsListResponse);
|
||||
},
|
||||
```
|
||||
|
||||
## Example usage
|
||||
|
||||
In this example we'll create a task that generates a random joke using OpenAI GPT 3.5 .
|
||||
|
||||
```ts example.ts
|
||||
import { TriggerClient, eventTrigger } from "@trigger.dev/sdk";
|
||||
import { OpenAI } from "@trigger.dev/openai";
|
||||
import { z } from "zod";
|
||||
|
||||
// Initialize a TriggerClient with the ID "jobs-showcase"
|
||||
const client = new TriggerClient({ id: "jobs-showcase" });
|
||||
|
||||
// Create an instance of the OpenAI client and provide the OpenAI API key from environment variables
|
||||
const openai = new OpenAI({
|
||||
id: "openai",
|
||||
apiKey: process.env.OPENAI_API_KEY!, // Replace with your actual OpenAI API key
|
||||
});
|
||||
|
||||
// Define a job that uses OpenAI GPT-3.5 Turbo to tell jokes
|
||||
client.defineJob({
|
||||
id: "openai-tell-me-a-joke",
|
||||
name: "OpenAI: tell me a joke",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks", // Define the trigger event name
|
||||
schema: z.object({
|
||||
jokePrompt: z.string(), // Expect a joke prompt as input
|
||||
}),
|
||||
}),
|
||||
integrations: {
|
||||
openai, // Use the OpenAI integration for this job
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
// Retrieve information about the GPT-3.5 Turbo model
|
||||
await io.openai.retrieveModel("get-model", {
|
||||
model: "gpt-3.5-turbo",
|
||||
});
|
||||
|
||||
// List available models (optional, for reference)
|
||||
const models = await io.openai.listModels("list-models");
|
||||
|
||||
// Generate a joke in the background using the chat conversation format
|
||||
const jokeResult = await io.openai.backgroundCreateChatCompletion(
|
||||
"background-chat-completion",
|
||||
{
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: payload.jokePrompt, // User-provided joke prompt
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
// Return the generated joke as the result
|
||||
return {
|
||||
joke: jokeResult.choices[0]?.message?.content,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// These lines are specific to the Express framework and can be removed if not needed
|
||||
import { createExpressServer } from "@trigger.dev/express";
|
||||
createExpressServer(client);
|
||||
```
|
||||
@@ -1,17 +1,21 @@
|
||||
---
|
||||
title: OpenAI
|
||||
title: OpenAI overview & authentication
|
||||
sidebarTitle: Overview & authentication
|
||||
---
|
||||
|
||||
Trigger.dev has a seamless integration with OpenAI, enabling developers to harness the power of AI
|
||||
language models in their serverless applications. With Trigger.dev's background tasks, long-running
|
||||
OpenAI completions become possible, even within the constraints of serverless timeouts.
|
||||
## Overview
|
||||
|
||||
<Snippet file="integration-getting-started.mdx" />
|
||||
Our OpenAI integration allows you to easily perform AI-powered tasks, such as summarizing text, answering questions, generating images, fine tuning and much more.
|
||||
|
||||
## Installation
|
||||
<Card
|
||||
title="Jobs Showcase - OpenAI"
|
||||
icon="rocket"
|
||||
href="https://trigger.dev/showcase?tags=&integrations=openai"
|
||||
>
|
||||
Check out pre-built OpenAI jobs in our showcase.
|
||||
</Card>
|
||||
|
||||
To get started with the OpenAI integration on Trigger.dev, you need to install the `@trigger.dev/openai` package.
|
||||
You can do this using npm, pnpm, or yarn:
|
||||
## Installing the OpenAI packages
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -43,84 +47,47 @@ const openai = new OpenAI({
|
||||
});
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Tasks
|
||||
|
||||
Include the OpenAI integration in your Trigger.dev job:
|
||||
Once you have set up a OpenAI client, you can use it to create tasks.
|
||||
|
||||
<CardGroup>
|
||||
<Card title="Tasks" icon="sparkles" href="/integrations/apis/openai-tasks">
|
||||
Perform different AI-powered tasks using OpenAI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Usage as Universal Client
|
||||
|
||||
You can use our OpenAI integration as a universal client to interact with other OpenAI-compatible APIs, such as [Perplexity.ai](https://docs.perplexity.ai/)
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-tasks",
|
||||
name: "OpenAI Tasks",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
// Now you can access the OpenAI tasks through the io object
|
||||
await io.openai.createCompletion("completion", {
|
||||
model: "davinci",
|
||||
prompt: "Once upon a time",
|
||||
});
|
||||
},
|
||||
import { OpenAI } from "@trigger.dev/openai";
|
||||
|
||||
const perplexity = new OpenAI({
|
||||
id: "perplexity",
|
||||
apiKey: process.env["PERPLEXITY_API_KEY"]!,
|
||||
baseURL: "https://api.perplexity.ai",
|
||||
icon: "brand-open-source",
|
||||
});
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
Tasks that are marked as "long-running" can last longer than your serverless timeout – they are performed on one of our background workers.
|
||||
|
||||
| Function Name | Description | Long-running? |
|
||||
| -------------------------------- | ------------------------------------------------------------------------- | ------------- |
|
||||
| `createCompletion` | Generates text completions given a prompt. |
|
||||
| `backgroundCreateCompletion` | Generates text completions in the background. | ✔ |
|
||||
| `createChatCompletion` | Generates text completions in a conversational context. |
|
||||
| `backgroundCreateChatCompletion` | Generates text completions in a conversational context in the background. | ✔ |
|
||||
| `retrieveModel` | Retrieves a specific model by ID. |
|
||||
| `listModels` | Lists the available models. |
|
||||
| `createEdit` | Edits a given text prompt. |
|
||||
| `createImage` | Generates images from textual descriptions. |
|
||||
| `createImageEdit` | Creates an edited or extended image given an original image and a prompt |
|
||||
| `createImageVariation` | Creates a variation of a given image. |
|
||||
| `createEmbedding` | Generates embeddings for a given text. |
|
||||
| `createFile` | Uploads a file to the OpenAI API. |
|
||||
| `listFiles` | Lists the uploaded files. |
|
||||
| `createFineTuneFile` | Uploads a file for fine-tuning a model. |
|
||||
| `createFineTune` | Fine-tunes a model on a given task. |
|
||||
| `listFineTunes` | Lists the available fine-tunes. |
|
||||
| `retrieveFineTune` | Retrieves a specific fine-tune by ID. |
|
||||
| `cancelFineTune` | Cancels a specific fine-tune by ID. |
|
||||
| `createFineTuningJob` | Creates a job that fine-tunes a specified model from a given dataset. |
|
||||
| `retrieveFineTuningJob` | Get info about a fine-tuning job. |
|
||||
| `cancelFineTuningJob` | Cancel a fine-tuning job |
|
||||
| `listFineTuningJobEvents` | List events for a fine-tuning job |
|
||||
| `listFineTuningJobs` | List fine tuning jobs |
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate a joke
|
||||
|
||||
Here's an example of how to use the OpenAI integration in a Trigger.dev job.
|
||||
In this example, we'll create a background task to generate a programming joke.
|
||||
Since [Perplexity.ai](https://docs.perplexity.ai/) is compatible with OpenAI, you can use the same tasks as with OpenAI.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-tasks",
|
||||
name: "OpenAI Tasks",
|
||||
id: "perplexity-tasks",
|
||||
name: "Perplexity Tasks",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks",
|
||||
schema: z.object({}),
|
||||
name: "perplexity.tasks",
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
perplexity,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.openai.backgroundCreateChatCompletion("background-chat-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
await io.perplexity.chat.completions.create("chat-completion", {
|
||||
model: "mistral-7b-instruct",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
@@ -129,190 +96,20 @@ client.defineJob({
|
||||
],
|
||||
});
|
||||
|
||||
await io.logger.info("choices", response.choices);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Generate Code Snippets
|
||||
|
||||
In this example, we'll leverage Trigger.dev's background task to generate code snippets for
|
||||
a given programming task:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-tasks",
|
||||
name: "OpenAI Tasks",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const programmingTask = `Create a function that checks if a string is a palindrome.`;
|
||||
|
||||
const response = await io.openai.backgroundCreateCompletion("background-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
prompt: `Coding task: ${programmingTask}\n\n`,
|
||||
});
|
||||
|
||||
await io.logger.info("codeSnippet", response.choices[0]?.text);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Summarize Text
|
||||
|
||||
We'll use Trigger.dev's background task to summarize a lengthy article:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-tasks",
|
||||
name: "OpenAI Tasks",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const articleToSummarize = `Lorem ipsum. olor sit amet, consectetur adipiscing elit.
|
||||
Sed nec aliquet sapien. Pellentesque vitae nisi id purus luctus tincidunt.
|
||||
Proin condimentum malesuada turpis, eget tincidunt mauris viverra in.`;
|
||||
|
||||
const response = await io.openai.backgroundCreateCompletion("background-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
prompt: `Please summarize the following article:\n\n${articleToSummarize}`,
|
||||
});
|
||||
|
||||
await io.logger.info("summary", response.choices[0]?.text);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Draft Email Response
|
||||
|
||||
we'll use Trigger.dev's background task to draft an email response based on a given email content:
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-tasks",
|
||||
name: "OpenAI Tasks",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.tasks",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const emailContent = `Dear John,
|
||||
|
||||
Thank you for your inquiry. We appreciate your interest in our products.
|
||||
I have reviewed your request, and I'm pleased to inform you that we can
|
||||
accommodate your requirements. Please find the attached proposal for your
|
||||
reference. If you have any further questions, feel free to ask.
|
||||
|
||||
Best regards,
|
||||
Jane Doe`;
|
||||
|
||||
const response = await io.openai.backgroundCreateChatCompletion("background-chat-completion", {
|
||||
model: "gpt-3.5-turbo",
|
||||
// Run this in the background
|
||||
await io.perplexity.chat.completions.backgroundCreate("background-chat-completion", {
|
||||
model: "mistral-7b-instruct",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: emailContent,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Draft a suitable response to the email above.",
|
||||
content: "If you were a programming language, what would you be and why?",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await io.logger.info("draftedEmailResponse", response.choices[0]?.text);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Chatbot Counseling Session
|
||||
And you'll get the same experience in the Run Dashboard when viewing the logs:
|
||||
|
||||
This job represents a simulated AI counseling session. Leveraging OpenAI's ability to understand context and generate human-like text, it forms empathetic responses to user inputs. Such a system could be part of a mental wellness app.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-chatbot-counseling",
|
||||
name: "Chatbot Counseling Session",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.startCounselingSession",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.openai.backgroundCreateChatCompletion(
|
||||
"background-counseling-chat-completion",
|
||||
{
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful and empathetic AI counselor.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "I've been feeling really stressed out lately.",
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
await io.logger.info("counseling session", response.choices);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### AI Roleplay Game Session
|
||||
|
||||
This job creates a fantasy AI role-playing game. It could be fun for interactive storytelling or game development contexts.
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "openai-roleplay-game-session",
|
||||
name: "AI Roleplay Game Session",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "openai.startRoleplayGameSession",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
openai,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.openai.backgroundCreateChatCompletion(
|
||||
"background-roleplay-game-session-chat-completion",
|
||||
{
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are an intelligent guide in a fantasy role-playing game.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "I embark on a quest for the enchanted crown. What's the first step?",
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
await io.logger.info("roleplay game session", response.choices);
|
||||
},
|
||||
});
|
||||
```
|
||||

|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: Plain tasks
|
||||
sidebarTitle: Tasks
|
||||
---
|
||||
|
||||
Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want.
|
||||
|
||||
---
|
||||
|
||||
## All tasks
|
||||
|
||||
### `getCustomerById`
|
||||
|
||||
Gets a customer using their id. [Official Plain Docs](https://plain-typescript-sdk-docs.vercel.app/classes/PlainClient.html#getCustomerById)
|
||||
|
||||
```ts example.ts
|
||||
const getCustomer = await io.plain.getCustomerById("get-customer", {
|
||||
customerId: customer.id, // The customer's unique identifier, likely an ID.
|
||||
});
|
||||
```
|
||||
|
||||
### `upsertCustomer`
|
||||
|
||||
Creates or updates a customer. [Official Plain Docs](https://plain-typescript-sdk-docs.vercel.app/classes/PlainClient.html#upsertCustomer)
|
||||
|
||||
```ts example.ts
|
||||
run: async (payload, io, ctx) => {
|
||||
// Inside this function, a new customer record is being upserted (inserted or updated).
|
||||
|
||||
// Using object destructuring to extract the 'customer' object from the result of the asynchronous 'upsertCustomer' function.
|
||||
const { customer } = await io.plain.upsertCustomer("upsert-customer", {
|
||||
// Configuration for upserting a customer record:
|
||||
|
||||
// Unique identifier for the customer record, in this case, based on the email address.
|
||||
identifier: {
|
||||
emailAddress: "rick.astley@gmail.com",
|
||||
},
|
||||
|
||||
// 'onCreate' block defines what to do when a new customer gets created with the given identifier.
|
||||
onCreate: {
|
||||
email: {
|
||||
email: "rick.astley@gmail.com", // Setting the email address.
|
||||
isVerified: true, // Marking the email as verified.
|
||||
},
|
||||
fullName: "Rick Astley", // Setting the full name.
|
||||
externalId: "u_123", // Assigning an external identifier.
|
||||
},
|
||||
|
||||
// 'onUpdate' block defines what to do when a customer with the given identifier already exists.
|
||||
onUpdate: {
|
||||
fullName: {
|
||||
value: "Rick Astley", // Updating the full name.
|
||||
},
|
||||
externalId: {
|
||||
value: "u_123", // Updating the external identifier.
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### `upsertCustomTimelineEntry`
|
||||
|
||||
Creates or updates a timeline entry. [Official Plain Docs](https://plain-typescript-sdk-docs.vercel.app/classes/PlainClient.html#upsertCustomTimelineEntry)
|
||||
|
||||
```ts example.ts
|
||||
// Declaring a constant 'timelineEntry' and using 'await' to asynchronously execute the 'upsertCustomTimelineEntry' function.
|
||||
const timelineEntry = await io.plain.upsertCustomTimelineEntry("upsert-timeline-entry", {
|
||||
// Specifying properties for the timeline entry:
|
||||
customerId: customer.id, // Assigning the 'customer.id' to the 'customerId' property.
|
||||
title: "My timeline entry", // Assigning the title for the timeline entry.
|
||||
|
||||
// Defining an array of 'components' that make up the timeline entry.
|
||||
components: [
|
||||
{
|
||||
// First component: A text component.
|
||||
componentText: {
|
||||
text: `This is a nice title`, // The text to display in this component.
|
||||
},
|
||||
},
|
||||
{
|
||||
// Second component: A divider component.
|
||||
componentDivider: {
|
||||
dividerSpacingSize: ComponentDividerSpacingSize.M, // Configuring the size of the divider.
|
||||
},
|
||||
},
|
||||
{
|
||||
// Third component: Another text component with additional properties.
|
||||
componentText: {
|
||||
textSize: ComponentTextSize.S, // Configuring the text size.
|
||||
textColor: ComponentTextColor.Muted, // Configuring the text color.
|
||||
text: "External id", // The text to display in this component.
|
||||
},
|
||||
},
|
||||
{
|
||||
// Fourth component: A text component that displays the 'externalId' property.
|
||||
componentText: {
|
||||
text: foundCustomer?.externalId ?? "", // Displaying the 'externalId' if available; otherwise, an empty string.
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
## Using the underlying client
|
||||
|
||||
You can also use the underlying client to do anything [@team-plain/typescript-sdk](https://github.com/team-plain/typescript-sdk) supports by using runTask:
|
||||
|
||||
```ts example.ts
|
||||
import { Plain } from "@trigger.dev/plain";
|
||||
|
||||
// Creating a new instance of the 'Plain' class and exporting it as 'plain'.
|
||||
export const plain = new Plain({
|
||||
id: "plain", // Unique identifier for this 'Plain' instance.
|
||||
apiKey: process.env.PLAIN_API_KEY!, // API key obtained from an environment variable.
|
||||
});
|
||||
|
||||
// Defining a job using the 'defineJob' method on the 'client' object.
|
||||
client.defineJob({
|
||||
id: "plain-client", // Unique identifier for this job.
|
||||
name: "Plain Client", // Setting a name for the job.
|
||||
version: "0.1.0", // Version number for this job.
|
||||
integrations: { plain }, // Integrating the 'plain' object into this job.
|
||||
trigger: eventTrigger({
|
||||
name: "plain.client", // Setting an event trigger with a name.
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
// Inside the 'run' function, a task is being executed asynchronously.
|
||||
|
||||
// Executing a task using the 'io.plain.runTask' method.
|
||||
const result = await io.plain.runTask(
|
||||
"create-issue", // Task name.
|
||||
async (client) =>
|
||||
client.createIssue({
|
||||
customerId: "abcdefghij",
|
||||
issueTypeId: "123456",
|
||||
}),
|
||||
{ name: "Create issue" }
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
## Example usage
|
||||
|
||||
In this example we'll create a task that updates or creates customer information based on an identifier.
|
||||
|
||||
```ts example.ts
|
||||
import { Job, TriggerClient, eventTrigger } from "@trigger.dev/sdk";
|
||||
import {
|
||||
ComponentDividerSpacingSize,
|
||||
ComponentTextColor,
|
||||
ComponentTextSize,
|
||||
Plain,
|
||||
} from "@trigger.dev/plain";
|
||||
|
||||
// Creating an instance of the TriggerClient with a unique identifier "jobs-showcase."
|
||||
const client = new TriggerClient({ id: "jobs-showcase" });
|
||||
|
||||
// Creating an instance of the Plain client. It uses an API key from an environment variable.
|
||||
export const plain = new Plain({
|
||||
id: "plain",
|
||||
apiKey: process.env.PLAIN_API_KEY!,
|
||||
});
|
||||
|
||||
// Defining a job for updating customer information.
|
||||
client.defineJob({
|
||||
id: "plain-update-customer", // Unique identifier for the job.
|
||||
name: "Plain: update customer", // A specific name for the job.
|
||||
version: "1.0.0", // Version number for the job.
|
||||
integrations: {
|
||||
plain, // Integrating the Plain client into this job.
|
||||
},
|
||||
trigger: eventTrigger({
|
||||
name: "plain.update.customer", // Setting an event trigger with a specific name.
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
// Inside the 'run' function, an operation is performed to update customer information.
|
||||
|
||||
// Retrieving the 'customer' object using the 'io.plain.upsertCustomer' method.
|
||||
const { customer } = await io.plain.upsertCustomer("upsert-customer", {
|
||||
identifier: {
|
||||
emailAddress: "rick.astley@gmail.com",
|
||||
},
|
||||
// If the customer isn't found, they will be created with the following details.
|
||||
onCreate: {
|
||||
email: {
|
||||
email: "rick.astley@gmail.com",
|
||||
isVerified: true,
|
||||
},
|
||||
fullName: "Rick Astley",
|
||||
externalId: "u_123",
|
||||
},
|
||||
// If the customer is found, their details will be updated.
|
||||
onUpdate: {
|
||||
fullName: {
|
||||
value: "Rick Astley",
|
||||
},
|
||||
externalId: {
|
||||
value: "u_123",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// If you're not using Express, you can remove these lines.
|
||||
import { createExpressServer } from "@trigger.dev/express";
|
||||
createExpressServer(client);
|
||||
```
|
||||
@@ -1,11 +1,23 @@
|
||||
---
|
||||
title: Plain
|
||||
description: Plain is customer support for developer tools
|
||||
title: Plain overview & authentication
|
||||
sidebarTitle: Overview & authentication
|
||||
---
|
||||
|
||||
<Snippet file="integration-getting-started.mdx" />
|
||||
## Overview
|
||||
|
||||
## Installation
|
||||
Plain is the customer support tool for technical teams and products.
|
||||
It aims to bring engineering and customer service teams together by creating a modern opinionated platform that's fantastic to build with.
|
||||
|
||||
|
||||
<Card
|
||||
title="Jobs Showcase - Plain"
|
||||
icon="rocket"
|
||||
href="https://trigger.dev/showcase?tags=&integrations=plain"
|
||||
>
|
||||
Check out pre-built Plain jobs in our showcase.
|
||||
</Card>
|
||||
|
||||
## Installing the Plain packages
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -36,130 +48,12 @@ export const plain = new Plain({
|
||||
});
|
||||
```
|
||||
|
||||
## Create customers and timeline entries
|
||||
|
||||
The Plain Integration allows you to create/update customers and add timeline entries.
|
||||
|
||||
```ts
|
||||
import { client } from "@/trigger";
|
||||
import { Job } from "@trigger.dev/sdk";
|
||||
import { Plain } from "@trigger.dev/plain";
|
||||
|
||||
export const plain = new Plain({
|
||||
id: "plain",
|
||||
apiKey: process.env.PLAIN_API_KEY!,
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "plain-playground",
|
||||
name: "Plain Playground",
|
||||
version: "0.1.1",
|
||||
integrations: {
|
||||
plain,
|
||||
},
|
||||
trigger: eventTrigger({
|
||||
name: "plain.playground",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
const { customer } = await io.plain.upsertCustomer("upsert-customer", {
|
||||
identifier: {
|
||||
emailAddress: "rick.astley@gmail.com",
|
||||
},
|
||||
onCreate: {
|
||||
email: {
|
||||
email: "rick.astley@gmail.com",
|
||||
isVerified: true,
|
||||
},
|
||||
fullName: "Rick Astley",
|
||||
externalId: "u_123",
|
||||
},
|
||||
onUpdate: {
|
||||
fullName: {
|
||||
value: "Rick Astley",
|
||||
},
|
||||
externalId: {
|
||||
value: "u_123",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const foundCustomer = await io.plain.getCustomerById("get-customer", {
|
||||
customerId: customer.id,
|
||||
});
|
||||
|
||||
const timelineEntry = await io.plain.upsertCustomTimelineEntry("upsert-timeline-entry", {
|
||||
customerId: customer.id,
|
||||
title: "My timeline entry",
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text: `This is a nice title`,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: ComponentDividerSpacingSize.M,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
textSize: ComponentTextSize.S,
|
||||
textColor: ComponentTextColor.Muted,
|
||||
text: "External id",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: foundCustomer?.externalId ?? "",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
## All tasks
|
||||
Once you have set up a Plain client, you can use it to create tasks.
|
||||
|
||||
| Function Name | Description |
|
||||
| --------------------------- | ----------------------------------- |
|
||||
| `getCustomerById` | Gets a customer using their id |
|
||||
| `upsertCustomer` | Creates or updates a customer |
|
||||
| `upsertCustomTimelineEntry` | Creates or updates a timeline entry |
|
||||
|
||||
## Using the underlying client
|
||||
|
||||
You can use the underlying client to do anything [@team-plain/typescript-sdk](https://github.com/team-plain/typescript-sdk) supports by using runTask:
|
||||
|
||||
```ts
|
||||
import { Plain } from "@trigger.dev/plain";
|
||||
|
||||
//create client
|
||||
export const plain = new Plain({
|
||||
id: "plain",
|
||||
apiKey: process.env.PLAIN_API_KEY!,
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "plain-client",
|
||||
name: "Plain Client",
|
||||
version: "0.1.0",
|
||||
integrations: { plain },
|
||||
trigger: eventTrigger({
|
||||
name: "plain.client",
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
const result = await io.plain.runTask(
|
||||
"create-issue",
|
||||
async (client) =>
|
||||
client.createIssue({
|
||||
customerId: "abcdefghij",
|
||||
issueTypeId: "123456",
|
||||
}),
|
||||
{ name: "Create issue" }
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
<CardGroup>
|
||||
<Card title="Tasks" icon="sparkles" href="/integrations/apis/plain-tasks">
|
||||
Perform tasks such as creating/updating customers and adding timeline entries.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Slack tasks
|
||||
sidebarTitle: Tasks
|
||||
---
|
||||
|
||||
Tasks are executed after the job is triggered and are the main building blocks of a job. You can string together as many tasks as you want.
|
||||
|
||||
---
|
||||
|
||||
## All tasks
|
||||
|
||||
### `postMessage`
|
||||
|
||||
Post a message to a channel. [Official Slack Docs](https://api.slack.com/methods/chat.postMessage)
|
||||
|
||||
```ts example.ts
|
||||
// Send a Slack message using the io.slack.postMessage function
|
||||
await io.slack.postMessage("post message", {
|
||||
// Specify the target channel by providing its ID
|
||||
channel: "<your-channel-id>",
|
||||
// Set the text content of the message
|
||||
text: "<your-slack-message>",
|
||||
});
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "send-slack-message",
|
||||
name: "Send a Slack message",
|
||||
version: "1.0.0",
|
||||
trigger: eventTrigger({
|
||||
name: "send.slack.message",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
slack,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.slack.postMessage("post message", {
|
||||
channel: "<your-channge-id>",
|
||||
text: "<your-slack-message>",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## How to post rich messages to Slack
|
||||
|
||||
Use their [Block kit builder](https://api.slack.com/block-kit), and then use the `blocks` property to send the message.
|
||||
|
||||
To see this in action, check out our 'Daily Slack alert for Linear issues' [example job](https://trigger.dev/showcase/jobs/linearIssuesDailySlackAlert).
|
||||
|
||||
```ts linearIssuesDailySlackAlert.ts
|
||||
...
|
||||
await io.slack.postMessage("post message", {
|
||||
channel: process.env.SLACK_CHANNEL_ID!,
|
||||
// Include text for notifications and blocks to get a rich Slack message in the channel
|
||||
text: `You have ${inProgressIssues.nodes.length} 'In Progress' issues in Linear!`,
|
||||
// Create rich Slack messages with the Block Kit builder https://app.slack.com/block-kit-builder/
|
||||
blocks: inProgressIssues.nodes.flatMap((issue) => [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `⏳ *${issue.title}*`,
|
||||
},
|
||||
accessory: {
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View issue",
|
||||
emoji: true,
|
||||
},
|
||||
value: "click_me_123",
|
||||
url: issue.url,
|
||||
action_id: "button-action",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -1,8 +1,21 @@
|
||||
---
|
||||
title: Slack
|
||||
title: Slack overview & authentication
|
||||
sidebarTitle: Overview & authentication
|
||||
---
|
||||
|
||||
<Snippet file="integration-getting-started.mdx" />
|
||||
## Overview
|
||||
|
||||
Our Slack integration allows you to connect to the Slack API and post messages to Slack.
|
||||
|
||||
For examples of some of the things you can do with Slack, check out our Jobs Showcase:
|
||||
|
||||
<Card
|
||||
title="Jobs Showcase - Slack"
|
||||
icon="rocket"
|
||||
href="https://trigger.dev/showcase?tags=&integrations=slack"
|
||||
>
|
||||
Check out pre-built Slack jobs in our showcase.
|
||||
</Card>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -24,7 +37,11 @@ yarn add @trigger.dev/slack@latest
|
||||
|
||||
## Authentication
|
||||
|
||||
Slack supports OAuth
|
||||
Slack supports OAuth.
|
||||
|
||||
## OAuth
|
||||
|
||||
To use OAuth you can connect to Slack via the Trigger.dev [web app](https://cloud.trigger.dev). Click 'Integrations' in the side panel of any project, and configure Slack with the ID you want to use in your job and the required [scopes](https://api.slack.com/scopes).
|
||||
|
||||
```ts
|
||||
import { Slack } from "@trigger.dev/slack";
|
||||
@@ -34,31 +51,12 @@ const slack = new Slack({
|
||||
});
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
client.defineJob({
|
||||
id: "slack-test",
|
||||
name: "Slack test",
|
||||
version: "0.0.1",
|
||||
trigger: eventTrigger({
|
||||
name: "slack.test",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
integrations: {
|
||||
slack,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
const response = await io.slack.postMessage("post message", {
|
||||
channel: "C04GWUTDC3W",
|
||||
text: "My first Slack message",
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
| Function Name | Description |
|
||||
| ------------- | --------------------------- |
|
||||
| `postMessage` | Post a message to a channel |
|
||||
Once you have set up a Slack client, you can use it to create tasks.
|
||||
|
||||
<CardGroup>
|
||||
<Card title="Tasks" icon="sparkles" href="/integrations/apis/slack-tasks">
|
||||
Perform tasks such as posting messages to a channel.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
+54
-14
@@ -58,6 +58,14 @@
|
||||
{
|
||||
"source": "/documentation/guides/manual",
|
||||
"destination": "/documentation/guides/manual/nextjs"
|
||||
},
|
||||
{
|
||||
"source": "/documentation/concepts/limitations",
|
||||
"destination": "/documentation/concepts/limits"
|
||||
},
|
||||
{
|
||||
"source": "/documentation/guides/create-a-job",
|
||||
"destination": "/documentation/guides/writing-jobs-step-by-step"
|
||||
}
|
||||
],
|
||||
"navigation": [
|
||||
@@ -86,7 +94,7 @@
|
||||
"documentation/quickstarts/supabase"
|
||||
]
|
||||
},
|
||||
"documentation/guides/create-a-job",
|
||||
"documentation/guides/writing-jobs-step-by-step",
|
||||
"documentation/guides/video-walkthrough"
|
||||
]
|
||||
},
|
||||
@@ -94,7 +102,7 @@
|
||||
"group": "Concepts",
|
||||
"pages": [
|
||||
"documentation/concepts/what-is-triggerdotdev",
|
||||
"documentation/concepts/limitations",
|
||||
"documentation/concepts/limits",
|
||||
"documentation/concepts/projects",
|
||||
"documentation/concepts/client-adaptors",
|
||||
{
|
||||
@@ -238,7 +246,13 @@
|
||||
{
|
||||
"group": "Integrations",
|
||||
"pages": [
|
||||
"integrations/apis/airtable",
|
||||
{
|
||||
"group": "Airtable",
|
||||
"pages": [
|
||||
"integrations/apis/airtable",
|
||||
"integrations/apis/airtable-tasks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "GitHub",
|
||||
"pages": [
|
||||
@@ -248,12 +262,30 @@
|
||||
]
|
||||
},
|
||||
"integrations/apis/linear",
|
||||
"integrations/apis/openai",
|
||||
"integrations/apis/plain",
|
||||
{
|
||||
"group": "OpenAI",
|
||||
"pages": [
|
||||
"integrations/apis/openai",
|
||||
"integrations/apis/openai-tasks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Plain",
|
||||
"pages": [
|
||||
"integrations/apis/plain",
|
||||
"integrations/apis/plain-tasks"
|
||||
]
|
||||
},
|
||||
"integrations/apis/replicate",
|
||||
"integrations/apis/resend",
|
||||
"integrations/apis/sendgrid",
|
||||
"integrations/apis/slack",
|
||||
{
|
||||
"group": "Slack",
|
||||
"pages": [
|
||||
"integrations/apis/slack",
|
||||
"integrations/apis/slack-tasks"
|
||||
]
|
||||
},
|
||||
"integrations/apis/stripe",
|
||||
{
|
||||
"group": "Supabase",
|
||||
@@ -296,12 +328,12 @@
|
||||
"group": "IO",
|
||||
"pages": [
|
||||
"sdk/io/overview",
|
||||
"sdk/io/logger",
|
||||
"sdk/io/wait",
|
||||
"sdk/io/sendevent",
|
||||
"sdk/io/runtask",
|
||||
"sdk/io/try",
|
||||
"sdk/io/wait",
|
||||
"sdk/io/logger",
|
||||
"sdk/io/sendevent",
|
||||
"sdk/io/backgroundfetch",
|
||||
"sdk/io/try",
|
||||
"sdk/io/registerinterval",
|
||||
"sdk/io/unregisterinterval",
|
||||
"sdk/io/registercron",
|
||||
@@ -320,7 +352,10 @@
|
||||
"sdk/dynamictrigger/constructor",
|
||||
{
|
||||
"group": "Instance methods",
|
||||
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
|
||||
"pages": [
|
||||
"sdk/dynamictrigger/register",
|
||||
"sdk/dynamictrigger/unregister"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -331,7 +366,10 @@
|
||||
"sdk/dynamicschedule/constructor",
|
||||
{
|
||||
"group": "Instance methods",
|
||||
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
|
||||
"pages": [
|
||||
"sdk/dynamicschedule/register",
|
||||
"sdk/dynamicschedule/unregister"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -352,7 +390,9 @@
|
||||
},
|
||||
{
|
||||
"group": "Overview",
|
||||
"pages": ["examples/introduction"]
|
||||
"pages": [
|
||||
"examples/introduction"
|
||||
]
|
||||
}
|
||||
],
|
||||
"footerSocials": {
|
||||
@@ -365,4 +405,4 @@
|
||||
"apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ Used to send log messages to the [Run log](/documentation/guides/viewing-runs).
|
||||
|
||||
## Instance methods
|
||||
|
||||
### [runTask()](/sdk/io/runtask)
|
||||
|
||||
`io.runTask()` allows you to run a [Task](/documentation/concepts/tasks) from inside a Job run. A Task is a resumable unit of a Run that can be retried, resumed and is logged. [Integrations](/integrations) use Tasks internally to perform their actions.
|
||||
|
||||
### [wait()](/sdk/io/wait)
|
||||
|
||||
Waits for a certain amount of time before continuing the Job. Delays works even if you're on a serverless platform with timeouts, or if your server goes down. They utilize [resumability](/documentation/concepts/resumability) to ensure that the Run can be resumed after the delay.
|
||||
@@ -32,18 +36,14 @@ Waits for a certain amount of time before continuing the Job. Delays works even
|
||||
|
||||
If you want to send an event from outside a run (e.g. just from your backend) you can use [client.sendEvent()](/sdk/triggerclient/instancemethods/sendevent).
|
||||
|
||||
### [runTask()](/sdk/io/runtask)
|
||||
### [backgroundFetch()](/sdk/io/backgroundfetch)
|
||||
|
||||
`io.runTask()` allows you to run a [Task](/documentation/concepts/tasks) from inside a Job run. A Task is a resumable unit of a Run that can be retried, resumed and is logged. [Integrations](/integrations) use Tasks internally to perform their actions.
|
||||
`io.backgroundFetch()` allows you to fetch data from a URL that can take longer that the serverless timeout. The actual `fetch` request is performed on the Trigger.dev platform, and the response is sent back to you. An example use case is fetching data from a slow API, like some AI endpoints.
|
||||
|
||||
### [try()](/sdk/io/try)
|
||||
|
||||
`io.try()` allows you to run Tasks and catch any errors that are thrown, it's similar to a normal `try/catch` block but works with [io.runTask()](/sdk/io/runtask).
|
||||
|
||||
### [backgroundFetch()](/sdk/io/backgroundfetch)
|
||||
|
||||
`io.backgroundFetch()` allows you to fetch data from a URL that can take longer that the serverless timeout. The actual `fetch` request is performed on the Trigger.dev platform, and the response is sent back to you. An example use case is fetching data from a slow API, like some AI endpoints.
|
||||
|
||||
### [registerInterval()](/sdk/io/registerinterval)
|
||||
|
||||
`io.registerInterval()` allows you to register a [DynamicSchedule](/sdk/dynamicschedule) that will run on a regular interval.
|
||||
|
||||
+24
-26
@@ -4,9 +4,12 @@ sidebarTitle: "runTask()"
|
||||
description: "Creates and runs a Task inside a Run."
|
||||
---
|
||||
|
||||
A [Task](/documentation/concepts/tasks) is a resumable unit of a Run that can be retried, resumed and is logged. [Integrations](/integrations) use Tasks internally to perform their actions.
|
||||
A [Task](/documentation/concepts/tasks) is a cached unit of work in a Job Run that are logged to the Trigger.dev UI. You can use `io.runTask()` to create and run a Task inside a Run.
|
||||
|
||||
The wrappers at `io.integration.runTask()` expose the underlying Integration client as the first callback parameter (see examples on the right). They will have defaults set for options and `onError` handlers, but should otherwise be considered identical to raw `io.runTask()`.
|
||||
<Warning>
|
||||
Any interaction with an external service (database or API) should be wrapped in a Task. Failing to
|
||||
do so could result in repeated work when runs are resumed.
|
||||
</Warning>
|
||||
|
||||
## Parameters
|
||||
|
||||
@@ -60,8 +63,7 @@ The wrappers at `io.integration.runTask()` expose the underlying Integration cli
|
||||
</ResponseField>
|
||||
<ResponseField name="icon" type="string">
|
||||
The icon for the Task, it will appear in the logs. You can use the name of a
|
||||
company in lowercase, e.g. "github". Or any icon name that [Font
|
||||
Awesome](https://fontawesome.com/icons) supports.
|
||||
company in lowercase, e.g. "github". Or any icon name that [Tabler Icons](https://tabler-icons.io/) supports.
|
||||
</ResponseField>
|
||||
<ResponseField name="description" type="string">
|
||||
A description of the Task.
|
||||
@@ -160,32 +162,30 @@ If the remote callback feature `options.callback` is enabled, the Promise will i
|
||||
client.defineJob({
|
||||
id: "alert-on-new-github-issues",
|
||||
name: "Alert on new GitHub issues",
|
||||
version: "0.1.1",
|
||||
version: "1.0.0",
|
||||
trigger: github.triggers.repo({
|
||||
event: events.onIssueOpened,
|
||||
owner: "triggerdotdev",
|
||||
repo: "trigger.dev",
|
||||
}),
|
||||
integrations: {
|
||||
github,
|
||||
},
|
||||
run: async (payload, io, ctx) => {
|
||||
//runTask
|
||||
const response = await io.github.runTask(
|
||||
"create-card",
|
||||
async (client) => {
|
||||
//create a project card using the underlying GitHub Integration client
|
||||
return client.rest.projects.createCard({
|
||||
column_id: 123,
|
||||
note: "test",
|
||||
const record = await io.runTask(
|
||||
"sync-github-issue",
|
||||
async (task) => {
|
||||
return prisma.githubIssues.create({
|
||||
data: {
|
||||
number: payload.issue.number,
|
||||
title: payload.issue.title,
|
||||
body: payload.issue.body,
|
||||
url: payload.issue.html_url,
|
||||
repo: payload.repository.full_name,
|
||||
owner: payload.repository.owner.login,
|
||||
},
|
||||
});
|
||||
},
|
||||
//this is optional
|
||||
{ name: "Create card", icon: "github" }
|
||||
{ name: "Sync GitHub Issue", icon: "github" }
|
||||
);
|
||||
|
||||
//log the url of the created card
|
||||
await io.logger.info(response.data.url);
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -228,14 +228,12 @@ client.defineJob({
|
||||
name: "Remote Callback example",
|
||||
version: "0.1.1",
|
||||
trigger: eventTrigger({ name: "predict" }),
|
||||
integrations: { replicate },
|
||||
run: async (payload, io, ctx) => {
|
||||
//runTask
|
||||
const prediction = await io.replicate.runTask(
|
||||
const prediction = await io.runTask(
|
||||
"create-and-await-prediction",
|
||||
async (client, task) => {
|
||||
//create a prediction using the underlying Replicate Integration client
|
||||
await client.predictions.create({
|
||||
async (task) => {
|
||||
//create a prediction using a Replicate SDK instance
|
||||
await replicate.predictions.create({
|
||||
...payload,
|
||||
webhook: task.callbackUrl ?? "",
|
||||
webhook_events_filter: ["completed"],
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebarTitle: "Overview"
|
||||
description: "TriggerClient is used to create a client that connects to the Trigger.dev platform"
|
||||
---
|
||||
|
||||
A [TriggerClient](/documentation/concepts/client-adaptors) is used to connect to a specific [Project](/documentation/concepts/projects) by using an [API Key](/documentation/concepts/environments-apikeys).
|
||||
A [TriggerClient](/documentation/concepts/client-adaptors) is used to connect to a specific [Project](/documentation/concepts/projects) by using an [API Key](/documentation/concepts/environments-endpoints).
|
||||
|
||||
```ts Example
|
||||
export const client = new TriggerClient({
|
||||
|
||||
@@ -14,7 +14,9 @@ export class Chat {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const response = await client.chat.completions.create(params);
|
||||
const response = await client.chat.completions.create(params, {
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
});
|
||||
task.outputProperties = createTaskUsageProperties(response.usage);
|
||||
return response;
|
||||
},
|
||||
@@ -38,15 +40,23 @@ export class Chat {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task, io) => {
|
||||
let baseURL = client.baseURL ?? "https://api.openai.com/v1";
|
||||
if (baseURL.endsWith("/")) {
|
||||
baseURL = baseURL.slice(0, -1);
|
||||
}
|
||||
|
||||
const chatCompletionsURL = `${baseURL}/chat/completions`;
|
||||
|
||||
const response = await io.backgroundFetch<OpenAI.Chat.ChatCompletion>(
|
||||
"background",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
chatCompletionsURL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: redactString`Bearer ${client.apiKey}`,
|
||||
...(client.organization ? { "OpenAI-Organization": client.organization } : {}),
|
||||
"Idempotency-Key": task.idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
|
||||
@@ -17,7 +17,9 @@ export class Completions {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const response = await client.completions.create(params);
|
||||
const response = await client.completions.create(params, {
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
});
|
||||
task.outputProperties = createTaskUsageProperties(response.usage);
|
||||
return response;
|
||||
},
|
||||
@@ -41,15 +43,23 @@ export class Completions {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task, io) => {
|
||||
let baseURL = client.baseURL ?? "https://api.openai.com/v1";
|
||||
if (baseURL.endsWith("/")) {
|
||||
baseURL = baseURL.slice(0, -1);
|
||||
}
|
||||
|
||||
const chatCompletionsURL = `${baseURL}/chat/completions`;
|
||||
|
||||
const response = await io.backgroundFetch<OpenAI.Completion>(
|
||||
"background",
|
||||
"https://api.openai.com/v1/completions",
|
||||
chatCompletionsURL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: redactString`Bearer ${client.apiKey}`,
|
||||
...(client.organization ? { "OpenAI-Organization": client.organization } : {}),
|
||||
"Idempotency-Key": task.idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ export class Edits {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const response = await client.edits.create(params);
|
||||
const response = await client.edits.create(params, { idempotencyKey: task.idempotencyKey });
|
||||
task.outputProperties = createTaskUsageProperties(response.usage);
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -17,7 +17,9 @@ export class Embeddings {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const response = await client.embeddings.create(params);
|
||||
const response = await client.embeddings.create(params, {
|
||||
idempotencyKey: task.idempotencyKey,
|
||||
});
|
||||
task.outputProperties = createTaskUsageProperties(response.usage);
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -36,7 +36,10 @@ export class Files {
|
||||
file = params.file;
|
||||
}
|
||||
|
||||
return client.files.create({ file, purpose: params.purpose });
|
||||
return client.files.create(
|
||||
{ file, purpose: params.purpose },
|
||||
{ idempotencyKey: task.idempotencyKey }
|
||||
);
|
||||
},
|
||||
{
|
||||
name: "Create file",
|
||||
@@ -81,7 +84,10 @@ export class Files {
|
||||
params.fileName
|
||||
);
|
||||
|
||||
return client.files.create({ file, purpose: "fine-tune" });
|
||||
return client.files.create(
|
||||
{ file, purpose: "fine-tune" },
|
||||
{ idempotencyKey: task.idempotencyKey }
|
||||
);
|
||||
},
|
||||
{
|
||||
name: "Create fine tune file",
|
||||
|
||||
@@ -41,7 +41,7 @@ export class FineTunes {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
return client.fineTunes.create(params);
|
||||
return client.fineTunes.create(params, { idempotencyKey: task.idempotencyKey });
|
||||
},
|
||||
{
|
||||
name: "Create fine tune",
|
||||
@@ -94,7 +94,7 @@ export class FineTunes {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
return client.fineTunes.cancel(params.fineTuneId);
|
||||
return client.fineTunes.cancel(params.fineTuneId, { idempotencyKey: task.idempotencyKey });
|
||||
},
|
||||
{
|
||||
name: "Cancel fine tune",
|
||||
@@ -168,7 +168,7 @@ export class FineTunes {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
return client.fineTuning.jobs.create(params);
|
||||
return client.fineTuning.jobs.create(params, { idempotencyKey: task.idempotencyKey });
|
||||
},
|
||||
{
|
||||
name: "Create Fine Tuning Job",
|
||||
@@ -207,7 +207,7 @@ export class FineTunes {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
return client.fineTuning.jobs.cancel(params.id);
|
||||
return client.fineTuning.jobs.cancel(params.id, { idempotencyKey: task.idempotencyKey });
|
||||
},
|
||||
{
|
||||
name: "Cancel Fine Tuning Job",
|
||||
|
||||
@@ -64,7 +64,7 @@ export class Images {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
return client.images.generate(params);
|
||||
return client.images.generate(params, { idempotencyKey: task.idempotencyKey });
|
||||
},
|
||||
{
|
||||
name: "Create image",
|
||||
@@ -121,15 +121,18 @@ export class Images {
|
||||
typeof params.image === "string" ? await fileFromUrl(params.image) : params.image;
|
||||
const mask = typeof params.mask === "string" ? await fileFromUrl(params.mask) : params.mask;
|
||||
|
||||
const response = await client.images.edit({
|
||||
image: file,
|
||||
prompt: params.prompt,
|
||||
mask: mask,
|
||||
n: params.n,
|
||||
size: params.size,
|
||||
response_format: params.response_format,
|
||||
user: params.user,
|
||||
});
|
||||
const response = await client.images.edit(
|
||||
{
|
||||
image: file,
|
||||
prompt: params.prompt,
|
||||
mask: mask,
|
||||
n: params.n,
|
||||
size: params.size,
|
||||
response_format: params.response_format,
|
||||
user: params.user,
|
||||
},
|
||||
{ idempotencyKey: task.idempotencyKey }
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
@@ -182,13 +185,16 @@ export class Images {
|
||||
const file =
|
||||
typeof params.image === "string" ? await fileFromUrl(params.image) : params.image;
|
||||
|
||||
const response = await client.images.createVariation({
|
||||
image: file,
|
||||
n: params.n,
|
||||
size: params.size,
|
||||
response_format: params.response_format,
|
||||
user: params.user,
|
||||
});
|
||||
const response = await client.images.createVariation(
|
||||
{
|
||||
image: file,
|
||||
n: params.n,
|
||||
size: params.size,
|
||||
response_format: params.response_format,
|
||||
user: params.user,
|
||||
},
|
||||
{ idempotencyKey: task.idempotencyKey }
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ export class OpenAI implements TriggerIntegration {
|
||||
this.native = new OpenAIApi({
|
||||
apiKey: options.apiKey,
|
||||
organization: options.organization,
|
||||
baseURL: options.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ export class OpenAI implements TriggerIntegration {
|
||||
openai._client = new OpenAIApi({
|
||||
apiKey,
|
||||
organization: this._options.organization,
|
||||
baseURL: this._options.baseURL ?? "https://api.openai.com/v1",
|
||||
});
|
||||
return openai;
|
||||
}
|
||||
@@ -104,7 +106,7 @@ export class OpenAI implements TriggerIntegration {
|
||||
return callback(this._client, task, io);
|
||||
},
|
||||
{
|
||||
icon: "openai",
|
||||
icon: this._options.icon ?? "openai",
|
||||
retry: retry.standardBackoff,
|
||||
...(options ?? {}),
|
||||
connectionKey: this._connectionKey,
|
||||
|
||||
@@ -2,6 +2,8 @@ export type OpenAIIntegrationOptions = {
|
||||
id: string;
|
||||
apiKey?: string;
|
||||
organization?: string;
|
||||
baseURL?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type OpenAIIntegrationAuth = Omit<OpenAIIntegrationOptions, "id">;
|
||||
|
||||
@@ -59,6 +59,10 @@ program
|
||||
"The URI path to the API handler function to use for this project.",
|
||||
"/api/trigger"
|
||||
)
|
||||
.option(
|
||||
"-t, --tunnel <url>",
|
||||
"An optional custom tunnel URL. Use only if you already have an open tunnel to your local dev server."
|
||||
)
|
||||
.version(getVersion(), "-v, --version", "Display the version number")
|
||||
.action(async (path, options) => {
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,11 @@ export const DevCommandOptionsSchema = z.object({
|
||||
envFile: z.string().optional(),
|
||||
handlerPath: z.string(),
|
||||
clientId: z.string().optional(),
|
||||
tunnel: z
|
||||
.string()
|
||||
.url()
|
||||
.regex(/^(http|https).+/, "only http/https URLs are accepted")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type DevCommandOptions = z.infer<typeof DevCommandOptionsSchema>;
|
||||
@@ -43,6 +48,29 @@ const formattedDate = new Intl.DateTimeFormat("en", {
|
||||
|
||||
let runtime: JsRuntime;
|
||||
|
||||
type TunnelUrl = {
|
||||
type: "tunnel";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ResolvedUrl = {
|
||||
type: "resolved";
|
||||
hostname: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type ServerUrl = TunnelUrl | ResolvedUrl;
|
||||
|
||||
type TunnelEndpoint = TunnelUrl & {
|
||||
handlerPath: string;
|
||||
};
|
||||
|
||||
type ResolvedEndpoint = ResolvedUrl & {
|
||||
handlerPath: string;
|
||||
};
|
||||
|
||||
type ServerEndpoint = TunnelEndpoint | ResolvedEndpoint;
|
||||
|
||||
export async function devCommand(path: string, anyOptions: any) {
|
||||
telemetryClient.dev.started(path, anyOptions);
|
||||
|
||||
@@ -90,23 +118,23 @@ export async function devCommand(path: string, anyOptions: any) {
|
||||
logger.error(
|
||||
`✖ [trigger.dev] Your endpoint couldn't be verified. Make sure your app is running and try again. ${resolvedOptions.handlerPath}`
|
||||
);
|
||||
logger.info(` [trigger.dev] You can use -H to specify a hostname, or -p to specify a port.`);
|
||||
logger.info(
|
||||
` [trigger.dev] You can use -H to specify a hostname, or -p to specify a port, or -t to specify the tunnel-url pointing to the local dev server.`
|
||||
);
|
||||
telemetryClient.dev.failed("no_server_found", resolvedOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostname, port, handlerPath } = verifiedEndpoint;
|
||||
|
||||
telemetryClient.dev.serverRunning(path, resolvedOptions);
|
||||
|
||||
// Setup tunnel
|
||||
const endpointUrl = await resolveEndpointUrl(apiUrl, port, hostname);
|
||||
const endpointUrl = await resolveEndpointUrl(apiUrl, verifiedEndpoint);
|
||||
if (!endpointUrl) {
|
||||
telemetryClient.dev.failed("failed_to_create_tunnel", resolvedOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointHandlerUrl = `${endpointUrl}${handlerPath}`;
|
||||
const endpointHandlerUrl = `${endpointUrl}${verifiedEndpoint.handlerPath}`;
|
||||
telemetryClient.dev.tunnelRunning(path, resolvedOptions);
|
||||
|
||||
// Watch for changes to files and refresh endpoints
|
||||
@@ -306,6 +334,7 @@ async function resolveOptions(
|
||||
envFile: unresolvedOptions.envFile ?? ".env",
|
||||
handlerPath: unresolvedOptions.handlerPath,
|
||||
clientId: unresolvedOptions.clientId,
|
||||
tunnel: unresolvedOptions.tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,6 +347,7 @@ async function resolveOptions(
|
||||
envFile: unresolvedOptions.envFile ?? envName ?? ".env",
|
||||
handlerPath: unresolvedOptions.handlerPath,
|
||||
clientId: unresolvedOptions.clientId,
|
||||
tunnel: unresolvedOptions.tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,40 +357,15 @@ async function verifyEndpoint(
|
||||
apiKey: string,
|
||||
framework?: Framework
|
||||
) {
|
||||
//create list of hostnames to try
|
||||
const hostnames = [];
|
||||
if (resolvedOptions.hostname) {
|
||||
hostnames.push(resolvedOptions.hostname);
|
||||
}
|
||||
if (framework) {
|
||||
hostnames.push(...framework.defaultHostnames);
|
||||
} else {
|
||||
hostnames.push("localhost");
|
||||
}
|
||||
const serverUrls = findServerUrls(resolvedOptions, framework);
|
||||
|
||||
//create list of ports to try
|
||||
const ports = [];
|
||||
if (resolvedOptions.port) {
|
||||
ports.push(resolvedOptions.port);
|
||||
}
|
||||
if (framework) {
|
||||
ports.push(...framework.defaultPorts);
|
||||
} else {
|
||||
ports.push(3000);
|
||||
}
|
||||
|
||||
//create list of urls to try
|
||||
const urls: { hostname: string; port: number }[] = [];
|
||||
for (const hostname of hostnames) {
|
||||
for (const port of ports) {
|
||||
urls.push({ hostname, port });
|
||||
}
|
||||
}
|
||||
|
||||
//try each hostname
|
||||
for (const url of urls) {
|
||||
const { hostname, port } = url;
|
||||
const localEndpointHandlerUrl = `http://${hostname}:${port}${resolvedOptions.handlerPath}`;
|
||||
//try each url
|
||||
for (const serverUrl of serverUrls) {
|
||||
const url =
|
||||
serverUrl.type === "tunnel"
|
||||
? serverUrl.url
|
||||
: `http://${serverUrl.hostname}:${serverUrl.port}`;
|
||||
const localEndpointHandlerUrl = `${url}${resolvedOptions.handlerPath}`;
|
||||
|
||||
const spinner = ora(
|
||||
`[trigger.dev] Looking for your trigger endpoint: ${localEndpointHandlerUrl}`
|
||||
@@ -384,7 +389,8 @@ async function verifyEndpoint(
|
||||
}
|
||||
|
||||
spinner.succeed(`[trigger.dev] Found your trigger endpoint: ${localEndpointHandlerUrl}`);
|
||||
return { hostname, port, handlerPath: resolvedOptions.handlerPath };
|
||||
|
||||
return { ...serverUrl, handlerPath: resolvedOptions.handlerPath };
|
||||
} catch (err) {
|
||||
spinner.fail(`[trigger.dev] No server found (${localEndpointHandlerUrl}).`);
|
||||
}
|
||||
@@ -399,17 +405,61 @@ export function getEndpointId(runtime: JsRuntime, clientId?: string) {
|
||||
} else return runtime.getEndpointId();
|
||||
}
|
||||
|
||||
async function resolveEndpointUrl(apiUrl: string, port: number, hostname: string) {
|
||||
function findServerUrls(resolvedOptions: ResolvedOptions, framework?: Framework): ServerUrl[] {
|
||||
if (resolvedOptions.tunnel) {
|
||||
logger.info(` Using provided tunnel URL: ${resolvedOptions.tunnel}`);
|
||||
return [{ type: "tunnel", url: resolvedOptions.tunnel }];
|
||||
}
|
||||
|
||||
//create list of hostnames to try
|
||||
const hostnames = [];
|
||||
if (resolvedOptions.hostname) {
|
||||
hostnames.push(resolvedOptions.hostname);
|
||||
}
|
||||
if (framework) {
|
||||
hostnames.push(...framework.defaultHostnames);
|
||||
} else {
|
||||
hostnames.push("localhost");
|
||||
}
|
||||
|
||||
//create list of ports to try
|
||||
const ports = [];
|
||||
if (resolvedOptions.port) {
|
||||
ports.push(resolvedOptions.port);
|
||||
}
|
||||
if (framework) {
|
||||
ports.push(...framework.defaultPorts);
|
||||
} else {
|
||||
ports.push(3000);
|
||||
}
|
||||
|
||||
//create list of urls to try
|
||||
const urls: ResolvedUrl[] = [];
|
||||
for (const hostname of hostnames) {
|
||||
for (const port of ports) {
|
||||
urls.push({ type: "resolved", hostname, port });
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function resolveEndpointUrl(apiUrl: string, endpoint: ServerEndpoint) {
|
||||
// use tunnel URL if provided
|
||||
if (endpoint.type === "tunnel") {
|
||||
return endpoint.url;
|
||||
}
|
||||
|
||||
const apiURL = new URL(apiUrl);
|
||||
|
||||
//if the API is localhost and the hostname is localhost
|
||||
if (apiURL.hostname === "localhost" && hostname === "localhost") {
|
||||
return `http://${hostname}:${port}`;
|
||||
// if the API is localhost and the hostname is localhost
|
||||
if (apiURL.hostname === "localhost" && endpoint.hostname === "localhost") {
|
||||
return `http://${endpoint.hostname}:${endpoint.port}`;
|
||||
}
|
||||
|
||||
// Setup tunnel
|
||||
const tunnelSpinner = ora(`🚇 Creating tunnel`).start();
|
||||
const tunnelUrl = await createTunnel(hostname, port, tunnelSpinner);
|
||||
const tunnelUrl = await createTunnel(endpoint.hostname, endpoint.port, tunnelSpinner);
|
||||
|
||||
if (tunnelUrl) {
|
||||
tunnelSpinner.succeed(`🚇 Created tunnel: ${tunnelUrl}`);
|
||||
@@ -442,7 +492,7 @@ async function createTunnel(hostname: string, port: number, spinner: Ora) {
|
||||
error.message.includes("connect ECONNREFUSED 127.0.0.1:4041")
|
||||
) {
|
||||
spinner.fail(
|
||||
`Ngrok failed to create a tunnel for port ${port} because ngrok is already running`
|
||||
`Ngrok failed to create a tunnel for port ${port} because ngrok is already running.\n You may want to use -t flag to use an existing URL that points to the local dev server.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs/promises";
|
||||
import pathModule from "path";
|
||||
import boxen from "boxen";
|
||||
import { Framework } from "..";
|
||||
import { templatesPath } from "../../paths";
|
||||
import { InstallPackage } from "../../utils/addDependencies";
|
||||
@@ -50,17 +51,46 @@ export class NextJs implements Framework {
|
||||
}
|
||||
|
||||
const nextJsDir = await detectPagesOrAppDir(path);
|
||||
const nextJsVersion = await detectNextVersion(path);
|
||||
const routeDir = pathModule.join(path, usesSrcDir ? "src" : "");
|
||||
const pathAlias = await getPathAlias({
|
||||
projectPath: path,
|
||||
isTypescriptProject: options.typescript,
|
||||
extraDirectories: usesSrcDir ? ["src"] : undefined,
|
||||
});
|
||||
const fileExtension = options.typescript ? ".ts" : ".js";
|
||||
|
||||
if (nextJsDir === "pages") {
|
||||
await createTriggerPageRoute(routeDir, options.endpointSlug, options.typescript, pathAlias);
|
||||
const apiRoutePath = pathModule.join(routeDir, "pages", "api", `trigger${fileExtension}`);
|
||||
if (nextJsVersion && nextJsVersion !== 'latest' && nextJsVersion < "13.5") {
|
||||
await createTriggerRoute({
|
||||
path: routeDir,
|
||||
apiRoutePath,
|
||||
template: "pagesApiRoute.js",
|
||||
fileExtension,
|
||||
endpointSlug: options.endpointSlug,
|
||||
pathAlias
|
||||
});
|
||||
} else {
|
||||
await createTriggerRoute({
|
||||
path: routeDir,
|
||||
apiRoutePath,
|
||||
template: "pagesApiRouteWithConfigObject.js",
|
||||
fileExtension,
|
||||
endpointSlug: options.endpointSlug,
|
||||
pathAlias
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await createTriggerAppRoute(routeDir, options.endpointSlug, options.typescript, pathAlias);
|
||||
const apiRoutePath = pathModule.join(routeDir, "app", "api", "trigger", `route${fileExtension}`);
|
||||
await createTriggerRoute({
|
||||
path: routeDir,
|
||||
apiRoutePath,
|
||||
template: "appApiRoute.js",
|
||||
fileExtension,
|
||||
endpointSlug: options.endpointSlug,
|
||||
pathAlias
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,45 +174,34 @@ export async function detectPagesOrAppDir(path: string): Promise<"pages" | "app"
|
||||
return "pages";
|
||||
}
|
||||
|
||||
async function createTriggerPageRoute(
|
||||
path: string,
|
||||
endpointSlug: string,
|
||||
isTypescriptProject: boolean,
|
||||
pathAlias: string | undefined
|
||||
) {
|
||||
const templatesDir = pathModule.join(templatesPath(), "nextjs");
|
||||
const fileExtension = isTypescriptProject ? ".ts" : ".js";
|
||||
|
||||
//pages/api/trigger.js or src/pages/api/trigger.js
|
||||
const apiRoutePath = pathModule.join(path, "pages", "api", `trigger${fileExtension}`);
|
||||
const apiRouteResult = await createFileFromTemplate({
|
||||
templatePath: pathModule.join(templatesDir, "pagesApiRoute.js"),
|
||||
replacements: {
|
||||
routePathPrefix: pathAlias ? pathAlias + "/" : "../../",
|
||||
},
|
||||
outputPath: apiRoutePath,
|
||||
});
|
||||
if (!apiRouteResult.success) {
|
||||
throw new Error("Failed to create API route file");
|
||||
export async function detectNextVersion(path: string) {
|
||||
const packageJsonContent = await readPackageJson(path);
|
||||
if (!packageJsonContent) {
|
||||
return null;
|
||||
}
|
||||
logger.success(`✔ Created API route at ${apiRoutePath}`);
|
||||
|
||||
await createJobsAndTriggerFile(path, endpointSlug, fileExtension, pathAlias, templatesDir);
|
||||
const versionNumberPattern = /[\d.]+|latest/;
|
||||
if (packageJsonContent.dependencies?.next !== undefined)
|
||||
return packageJsonContent.dependencies?.next?.match(versionNumberPattern)?.at(0) ?? null;
|
||||
if (packageJsonContent.devDependencies?.next !== undefined)
|
||||
return packageJsonContent.devDependencies?.next?.match(versionNumberPattern)?.at(0) ?? null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createTriggerAppRoute(
|
||||
async function createTriggerRoute(options: {
|
||||
path: string,
|
||||
apiRoutePath: string,
|
||||
template: string,
|
||||
fileExtension: string,
|
||||
endpointSlug: string,
|
||||
isTypescriptProject: boolean,
|
||||
pathAlias: string | undefined
|
||||
) {
|
||||
const templatesDir = pathModule.join(templatesPath(), "nextjs");
|
||||
const fileExtension = isTypescriptProject ? ".ts" : ".js";
|
||||
}) {
|
||||
const { path, apiRoutePath, template, pathAlias, endpointSlug, fileExtension } = options;
|
||||
|
||||
//app/api/trigger/route.js or src/app/api/trigger/route.js
|
||||
const apiRoutePath = pathModule.join(path, "app", "api", "trigger", `route${fileExtension}`);
|
||||
const templatesDir = pathModule.join(templatesPath(), "nextjs");
|
||||
const apiRouteResult = await createFileFromTemplate({
|
||||
templatePath: pathModule.join(templatesDir, "appApiRoute.js"),
|
||||
templatePath: pathModule.join(templatesDir, template),
|
||||
replacements: {
|
||||
routePathPrefix: pathAlias ? pathAlias + "/" : "../../",
|
||||
},
|
||||
@@ -192,6 +211,14 @@ async function createTriggerAppRoute(
|
||||
throw new Error("Failed to create API route file");
|
||||
}
|
||||
logger.success(`✔ Created API route at ${apiRoutePath}`);
|
||||
logger.info(
|
||||
boxen(`If you're deploying to Vercel, configure your max duration in ${apiRoutePath}`, {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "double",
|
||||
borderColor: "magenta",
|
||||
})
|
||||
);
|
||||
|
||||
await createJobsAndTriggerFile(path, endpointSlug, fileExtension, pathAlias, templatesDir);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import mock from "mock-fs";
|
||||
import { NextJs, detectPagesOrAppDir, detectUseOfSrcDir } from ".";
|
||||
import { NextJs, detectPagesOrAppDir, detectUseOfSrcDir, detectNextVersion } from ".";
|
||||
import { getFramework } from "..";
|
||||
import { pathExists } from "../../utils/fileSystem";
|
||||
import { detectMiddlewareUsage } from "./middleware";
|
||||
@@ -57,6 +57,35 @@ describe("Next project detection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Next version detection", () => {
|
||||
test("detect Nextjs latest version", async () => {
|
||||
mock({
|
||||
"package.json": JSON.stringify({ dependencies: { next: "latest" } }),
|
||||
});
|
||||
|
||||
const nextJsVersion = await detectNextVersion("");
|
||||
expect(nextJsVersion).toEqual("latest");
|
||||
});
|
||||
|
||||
test("detect Nextjs 13.0.0 version", async () => {
|
||||
mock({
|
||||
"package.json": JSON.stringify({ dependencies: { next: "13.0.0" } }),
|
||||
});
|
||||
|
||||
const nextJsVersion = await detectNextVersion("");
|
||||
expect(nextJsVersion).toEqual("13.0.0");
|
||||
});
|
||||
|
||||
test("detect Nextjs version as a dev dependency", async () => {
|
||||
mock({
|
||||
"package.json": JSON.stringify({ devDependencies: { next: "^12.0.0" } }),
|
||||
});
|
||||
|
||||
const nextJsVersion = await detectNextVersion("");
|
||||
expect(nextJsVersion).toEqual("12.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("src directory", () => {
|
||||
test("has src directory", async () => {
|
||||
mock({
|
||||
|
||||
@@ -5,3 +5,6 @@ import "${routePathPrefix}jobs";
|
||||
|
||||
//this route is used to send and receive data with Trigger.dev
|
||||
export const { POST, dynamic } = createAppRoute(client);
|
||||
|
||||
//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration
|
||||
//export const maxDuration = 60;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createPagesRoute } from "@trigger.dev/nextjs";
|
||||
import { client } from "${routePathPrefix}trigger";
|
||||
|
||||
import "${routePathPrefix}jobs";
|
||||
|
||||
//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration
|
||||
//export const config = {
|
||||
// maxDuration: 60,
|
||||
//};
|
||||
|
||||
//this route is used to send and receive data with Trigger.dev
|
||||
const { handler, config } = createPagesRoute(client);
|
||||
export { config };
|
||||
|
||||
export default handler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user