Support ignoring test/spec files inside trigger dirs (fixes #1593) (#1596)

* Support ignoring test/spec files inside trigger dirs (fixes #1593)

* Add changeset
This commit is contained in:
Eric Allam
2025-01-10 19:40:08 +00:00
committed by GitHub
parent 914ceaf3c5
commit 9e0f03623d
17 changed files with 850 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Support ignoring test/spec files inside trigger dirs (fixes #1593)
+27
View File
@@ -47,6 +47,33 @@ The config file handles a lot of things, like:
imports used inside build config with be tree-shaken out.
</Note>
## Dirs
You can specify the directories where your tasks are located using the `dirs` option:
```ts trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk/v3";
export default defineConfig({
project: "<project ref>",
dirs: ["./trigger"],
});
```
If you omit the `dirs` option, we will automatically detect directories that are named `trigger` in your project, but we recommend specifying the directories explicitly. The `dirs` option is an array of strings, so you can specify multiple directories if you have tasks in multiple locations.
We will search for TypeScript and JavaScript files in the specified directories and include them in the build process. We automatically exclude files that have `.test` or `.spec` in the name, but you can customize this by specifying glob patterns in the `ignorePatterns` option:
```ts trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk/v3";
export default defineConfig({
project: "<project ref>",
dirs: ["./trigger"],
ignorePatterns: ["**/*.my-test.ts"],
});
```
## Lifecycle functions
You can add lifecycle functions to get notified when any task starts, succeeds, or fails using `onStart`, `onSuccess` and `onFailure`:
+50 -14
View File
@@ -1,7 +1,10 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Trigger.dev",
"openapi": ["/openapi.yml", "/v3-openapi.yaml"],
"openapi": [
"/openapi.yml",
"/v3-openapi.yaml"
],
"api": {
"playground": {
"mode": "simple"
@@ -128,7 +131,6 @@
"quick-start",
"video-walkthrough",
"how-it-works",
"upgrading-beta",
"limits"
]
},
@@ -137,20 +139,30 @@
"pages": [
{
"group": "Tasks",
"pages": ["tasks/overview", "tasks/schemaTask", "tasks/scheduled"]
"pages": [
"tasks/overview",
"tasks/schemaTask",
"tasks/scheduled"
]
},
"triggering",
"runs",
"apikeys",
{
"group": "Configuration",
"pages": ["config/config-file", "config/extensions/overview"]
"pages": [
"config/config-file",
"config/extensions/overview"
]
}
]
},
{
"group": "Development",
"pages": ["cli-dev", "run-tests"]
"pages": [
"cli-dev",
"run-tests"
]
},
{
"group": "Deployment",
@@ -160,7 +172,9 @@
"github-actions",
{
"group": "Deployment integrations",
"pages": ["vercel-integration"]
"pages": [
"vercel-integration"
]
}
]
},
@@ -172,7 +186,13 @@
"errors-retrying",
{
"group": "Wait",
"pages": ["wait", "wait-for", "wait-until", "wait-for-event", "wait-for-request"]
"pages": [
"wait",
"wait-for",
"wait-until",
"wait-for-event",
"wait-for-request"
]
},
"queue-concurrency",
"versioning",
@@ -217,7 +237,10 @@
"management/overview",
{
"group": "Tasks API",
"pages": ["management/tasks/trigger", "management/tasks/batch-trigger"]
"pages": [
"management/tasks/trigger",
"management/tasks/batch-trigger"
]
},
{
"group": "Runs API",
@@ -256,7 +279,9 @@
},
{
"group": "Projects API",
"pages": ["management/projects/runs"]
"pages": [
"management/projects/runs"
]
}
]
},
@@ -294,6 +319,7 @@
"pages": [
"troubleshooting",
"upgrading-packages",
"upgrading-beta",
"troubleshooting-alerts",
"troubleshooting-uptime-status",
"troubleshooting-github-issues",
@@ -302,11 +328,17 @@
},
{
"group": "Help",
"pages": ["community", "help-slack", "help-email"]
"pages": [
"community",
"help-slack",
"help-email"
]
},
{
"group": "",
"pages": ["guides/introduction"]
"pages": [
"guides/introduction"
]
},
{
"group": "Frameworks",
@@ -380,11 +412,15 @@
},
{
"group": "Dashboard",
"pages": ["guides/dashboard/creating-a-project"]
"pages": [
"guides/dashboard/creating-a-project"
]
},
{
"group": "Migrations",
"pages": ["guides/use-cases/upgrading-from-v2"]
"pages": [
"guides/use-cases/upgrading-from-v2"
]
}
],
"footerSocials": {
@@ -392,4 +428,4 @@
"github": "https://github.com/triggerdotdev",
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
}
}
}
+2
View File
@@ -91,6 +91,7 @@
"@trigger.dev/core": "workspace:3.3.9",
"c12": "^1.11.1",
"chalk": "^5.2.0",
"chokidar": "^3.6.0",
"cli-table3": "^0.6.3",
"commander": "^9.4.1",
"defu": "^6.1.4",
@@ -119,6 +120,7 @@
"terminal-link": "^3.0.0",
"tiny-invariant": "^1.2.0",
"tinyexec": "^0.3.1",
"tinyglobby": "^0.2.2",
"ws": "^8.18.0",
"xdg-app-paths": "^8.3.0",
"zod": "3.23.8",
+83 -45
View File
@@ -17,6 +17,7 @@ import {
telemetryEntryPoint,
} from "./packageModules.js";
import { buildPlugins } from "./plugins.js";
import { createEntryPointManager } from "./entryPoints.js";
export interface BundleOptions {
target: BuildTarget;
@@ -45,12 +46,30 @@ export type BundleResult = {
export async function bundleWorker(options: BundleOptions): Promise<BundleResult> {
const { resolvedConfig } = options;
// We need to add the package entry points here somehow
// Then we need to get them out of the build result into the build manifest
// taskhero/dist/esm/workers/dev.js
// taskhero/dist/esm/telemetry/loader.js
const entryPoints = await getEntryPoints(options.target, resolvedConfig);
const $buildPlugins = await buildPlugins(options.target, resolvedConfig);
let currentContext: esbuild.BuildContext | undefined;
const entryPointManager = await createEntryPointManager(
resolvedConfig.dirs,
resolvedConfig,
options.target,
typeof options.watch === "boolean" ? options.watch : false,
async (newEntryPoints) => {
if (currentContext) {
// Rebuild with new entry points
await currentContext.cancel();
await currentContext.dispose();
const buildOptions = await createBuildOptions({
...options,
entryPoints: newEntryPoints,
});
logger.debug("Rebuilding worker with options", buildOptions);
currentContext = await esbuild.context(buildOptions);
await currentContext.watch();
}
}
);
let initialBuildResult: (result: esbuild.BuildResult) => void;
const initialBuildResultPromise = new Promise<esbuild.BuildResult>(
@@ -63,12 +82,63 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
},
};
const buildOptions = await createBuildOptions({
...options,
entryPoints: entryPointManager.entryPoints,
buildResultPlugin,
});
let result: esbuild.BuildResult<typeof buildOptions>;
let stop: BundleResult["stop"];
logger.debug("Building worker with options", buildOptions);
if (options.watch) {
currentContext = await esbuild.context(buildOptions);
await currentContext.watch();
result = await initialBuildResultPromise;
if (result.errors.length > 0) {
throw new Error("Failed to build");
}
stop = async function () {
await entryPointManager.stop();
await currentContext?.dispose();
};
} else {
result = await esbuild.build(buildOptions);
stop = async function () {
await entryPointManager.stop();
};
}
const bundleResult = await getBundleResultFromBuild(
options.target,
options.cwd,
options.resolvedConfig,
result
);
if (!bundleResult) {
throw new Error("Failed to get bundle result");
}
return { ...bundleResult, stop };
}
// Helper function to create build options
async function createBuildOptions(
options: BundleOptions & { entryPoints: string[]; buildResultPlugin?: esbuild.Plugin }
): Promise<esbuild.BuildOptions & { metafile: true }> {
const customConditions = options.resolvedConfig.build?.conditions ?? [];
const conditions = [...customConditions, "trigger.dev", "module", "node"];
const buildOptions: esbuild.BuildOptions & { metafile: true } = {
entryPoints,
const $buildPlugins = await buildPlugins(options.target, options.resolvedConfig);
return {
entryPoints: options.entryPoints,
outdir: options.destination,
absWorkingDir: options.cwd,
bundle: true,
@@ -93,7 +163,11 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
inject: [...shims], // TODO: copy this into the working dir to work with Yarn PnP
jsx: options.jsxAutomatic ? "automatic" : undefined,
jsxDev: options.jsxAutomatic && options.target === "dev" ? true : undefined,
plugins: [...$buildPlugins, ...(options.plugins ?? []), buildResultPlugin],
plugins: [
...$buildPlugins,
...(options.plugins ?? []),
...(options.buildResultPlugin ? [options.buildResultPlugin] : []),
],
...(options.jsxFactory && { jsxFactory: options.jsxFactory }),
...(options.jsxFragment && { jsxFragment: options.jsxFragment }),
logLevel: "silent",
@@ -101,42 +175,6 @@ export async function bundleWorker(options: BundleOptions): Promise<BundleResult
"empty-glob": "silent",
},
};
let result: esbuild.BuildResult<typeof buildOptions>;
let stop: BundleResult["stop"];
logger.debug("Building worker with options", buildOptions);
if (options.watch) {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
result = await initialBuildResultPromise;
if (result.errors.length > 0) {
throw new Error("Failed to build");
}
stop = async function () {
await ctx.dispose();
};
} else {
result = await esbuild.build(buildOptions);
// Even when we're not watching, we still want some way of cleaning up the
// temporary directory when we don't need it anymore
stop = async function () {};
}
const bundleResult = await getBundleResultFromBuild(
options.target,
options.cwd,
options.resolvedConfig,
result
);
if (!bundleResult) {
throw new Error("Failed to get bundle result");
}
return { ...bundleResult, stop };
}
export async function getBundleResultFromBuild(
+128
View File
@@ -0,0 +1,128 @@
import { BuildTarget } from "@trigger.dev/core/v3";
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import * as chokidar from "chokidar";
import { glob } from "tinyglobby";
import { logger } from "../utilities/logger.js";
import { deployEntryPoints, devEntryPoints, telemetryEntryPoint } from "./packageModules.js";
type EntryPointManager = {
entryPoints: string[];
watcher?: chokidar.FSWatcher;
stop: () => Promise<void>;
};
const DEFAULT_IGNORE_PATTERNS = [
"**/*.test.ts",
"**/*.test.mts",
"**/*.test.cts",
"**/*.test.js",
"**/*.test.mjs",
"**/*.test.cjs",
"**/*.spec.ts",
"**/*.spec.mts",
"**/*.spec.cts",
"**/*.spec.js",
"**/*.spec.mjs",
"**/*.spec.cjs",
];
export async function createEntryPointManager(
dirs: string[],
config: ResolvedConfig,
target: BuildTarget,
watch: boolean,
onEntryPointsChange?: (entryPoints: string[]) => Promise<void>
): Promise<EntryPointManager> {
// Patterns to match files
const patterns = dirs.flatMap((dir) => [`${dir}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`]);
// Patterns to ignore
const ignorePatterns = config.ignorePatterns ?? DEFAULT_IGNORE_PATTERNS;
async function getEntryPoints() {
// Get initial entry points
const entryPoints = await glob(patterns, {
ignore: ignorePatterns,
absolute: false,
cwd: config.workingDir,
});
// Add required entry points
if (config.configFile) {
entryPoints.push(config.configFile);
}
if (target === "dev") {
entryPoints.push(...devEntryPoints);
} else {
entryPoints.push(...deployEntryPoints);
}
if (config.instrumentedPackageNames?.length ?? 0 > 0) {
entryPoints.push(telemetryEntryPoint);
}
// Sort to ensure consistent comparison
return entryPoints.sort();
}
const initialEntryPoints = await getEntryPoints();
logger.debug("Initial entry points", {
entryPoints: initialEntryPoints,
patterns,
cwd: config.workingDir,
});
let currentEntryPoints = initialEntryPoints;
// Only setup watcher if watch is true
let watcher: chokidar.FSWatcher | undefined;
if (watch && onEntryPointsChange) {
logger.debug("Watching entry points for changes", { dirs, cwd: config.workingDir });
// Watch the parent directories
watcher = chokidar.watch(patterns, {
ignored: ignorePatterns,
persistent: true,
ignoreInitial: true,
useFsEvents: false,
});
// Handle file changes
const updateEntryPoints = async (event: string, path: string) => {
logger.debug("Entry point change detected", { event, path });
const newEntryPoints = await getEntryPoints();
// Compare arrays to see if they're different
const hasChanged =
newEntryPoints.length !== currentEntryPoints.length ||
newEntryPoints.some((entry, index) => entry !== currentEntryPoints[index]);
if (hasChanged) {
logger.debug("Entry points changed", {
old: currentEntryPoints,
new: newEntryPoints,
});
currentEntryPoints = newEntryPoints;
await onEntryPointsChange(newEntryPoints);
}
};
watcher
.on("add", (path) => updateEntryPoints("add", path))
.on("addDir", (path) => updateEntryPoints("addDir", path))
.on("unlink", (path) => updateEntryPoints("unlink", path))
.on("unlinkDir", (path) => updateEntryPoints("unlinkDir", path))
.on("error", (error) => logger.error("Watcher error:", error));
}
return {
entryPoints: initialEntryPoints,
watcher,
stop: async () => {
await watcher?.close();
},
};
}
+3
View File
@@ -164,6 +164,9 @@ export async function startDevSession({
async function runBundle() {
eventBus.emit("buildStarted", "dev");
// Use glob to find initial entryPoints
// Use chokidar to watch for entryPoints changes (e.g. added or removed?)
// When there is a change, update entryPoints and start a new build with watch: true
const bundleResult = await bundleWorker({
target: "dev",
cwd: rawConfig.workingDir,
+68
View File
@@ -15,19 +15,70 @@ export type TriggerConfig = {
* @default "node"
*/
runtime?: BuildRuntime;
/**
* Specify the project ref for your trigger.dev tasks. This is the project ref that you get when you create a new project in the trigger.dev dashboard.
*/
project: string;
/**
* Specify the directories that contain your trigger.dev tasks. This is useful if you have multiple directories that contain tasks.
*
* We automatically detect directories named `trigger` to be task directories. You can override this behavior by specifying the directories here.
*
* @see @see https://trigger.dev/docs/config/config-file#dirs
*/
dirs?: string[];
/**
* Specify glob patterns to ignore when detecting task files. By default we ignore:
*
* - *.test.ts
* - *.spec.ts
* - *.test.mts
* - *.spec.mts
* - *.test.cts
* - *.spec.cts
* - *.test.js
* - *.spec.js
* - *.test.mjs
* - *.spec.mjs
* - *.test.cjs
* - *.spec.cjs
*
*/
ignorePatterns?: string[];
/**
* Instrumentations to use for OpenTelemetry. This is useful if you want to add custom instrumentations to your tasks.
*
* @see https://trigger.dev/docs/config/config-file#instrumentations
*/
instrumentations?: Array<Instrumentation>;
/**
* Specify a custom path to your tsconfig file. This is useful if you have a custom tsconfig file that you want to use.
*/
tsconfig?: string;
/**
* Specify the global retry options for your tasks. You can override this on a per-task basis.
*
* @see https://trigger.dev/docs/tasks/overview#retry-options
*/
retries?: {
enabledInDev?: boolean;
default?: RetryOptions;
};
/**
* The default machine preset to use for your deployed trigger.dev tasks. You can override this on a per-task basis.
* @default "small-1x"
*
* @see https://trigger.dev/docs/machines
*/
machine?: MachinePresetName;
/**
* Set the log level for the logger. Defaults to "info", so you will see "log", "info", "warn", and "error" messages, but not "debug" messages.
*
@@ -43,6 +94,8 @@ export type TriggerConfig = {
* Minimum value is 5 seconds
*
* Setting this value will effect all tasks in the project.
*
* @see https://trigger.dev/docs/tasks/overview#maxduration-option
*/
maxDuration?: number;
@@ -50,6 +103,7 @@ export type TriggerConfig = {
* Enable console logging while running the dev CLI. This will print out logs from console.log, console.warn, and console.error. By default all logs are sent to the trigger.dev backend, and not logged to the console.
*/
enableConsoleLogging?: boolean;
build?: {
/**
* Add custom conditions to the esbuild build. For example, if you are importing `ai/rsc`, you'll need to add "react-server" condition.
@@ -61,8 +115,21 @@ export type TriggerConfig = {
* - "node"
*/
conditions?: string[];
/**
* Add custom build extensions to the build process.
*
* @see https://trigger.dev/docs/config/config-file#extensions
*/
extensions?: BuildExtension[];
/**
* External dependencies to exclude from the bundle. This is useful if you want to keep some dependencies as external, and not bundle them with your code.
*
* @see https://trigger.dev/docs/config/config-file#external
*/
external?: string[];
jsx?: {
/**
* @default "React.createElement"
@@ -81,6 +148,7 @@ export type TriggerConfig = {
automatic?: boolean;
};
};
deploy?: {
env?: Record<string, string>;
};
+10 -3
View File
@@ -1117,6 +1117,9 @@ importers:
chalk:
specifier: ^5.2.0
version: 5.3.0
chokidar:
specifier: ^3.6.0
version: 3.6.0
cli-table3:
specifier: ^0.6.3
version: 0.6.3
@@ -1201,6 +1204,9 @@ importers:
tinyexec:
specifier: ^0.3.1
version: 0.3.1
tinyglobby:
specifier: ^0.2.2
version: 0.2.2
ws:
specifier: ^8.18.0
version: 8.18.0
@@ -14509,7 +14515,7 @@ packages:
arg: 5.0.2
cacache: 17.1.4
chalk: 4.1.2
chokidar: 3.5.3
chokidar: 3.6.0
dotenv: 16.4.5
esbuild: 0.17.6
esbuild-plugins-node-modules-polyfill: 1.6.1(esbuild@0.17.6)
@@ -14662,7 +14668,7 @@ packages:
dependencies:
'@remix-run/express': 2.1.0(express@4.18.2)(typescript@5.2.2)
'@remix-run/node': 2.1.0(typescript@5.2.2)
chokidar: 3.5.3
chokidar: 3.6.0
compression: 1.7.4
express: 4.18.2
get-port: 5.1.1
@@ -19115,6 +19121,7 @@ packages:
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
dev: false
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
@@ -32386,7 +32393,7 @@ packages:
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
'@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19)
blake3-wasm: 2.1.5
chokidar: 3.5.3
chokidar: 3.6.0
esbuild: 0.17.19
miniflare: 3.20240512.0
nanoid: 3.3.7
@@ -0,0 +1,20 @@
{"custom_id":"request-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What is the difference between narrow AI and general AI?"}],"max_tokens":150}}
{"custom_id":"request-2","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How do large language models like GPT-3 work?"}],"max_tokens":150}}
{"custom_id":"request-3","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What are some ethical concerns surrounding the development of AI?"}],"max_tokens":150}}
{"custom_id":"request-4","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"Can you explain the concept of transfer learning in AI?"}],"max_tokens":150}}
{"custom_id":"request-5","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What is the Turing test, and is it still relevant in modern AI?"}],"max_tokens":150}}
{"custom_id":"request-6","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How do neural networks mimic the human brain?"}],"max_tokens":150}}
{"custom_id":"request-7","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What are the main challenges in natural language processing?"}],"max_tokens":150}}
{"custom_id":"request-8","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How does reinforcement learning differ from supervised learning?"}],"max_tokens":150}}
{"custom_id":"request-9","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What is the role of attention mechanisms in transformer models?"}],"max_tokens":150}}
{"custom_id":"request-10","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"Can AI truly be creative, or is it just mimicking human creativity?"}],"max_tokens":150}}
{"custom_id":"request-11","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What are the potential implications of AI on the job market?"}],"max_tokens":150}}
{"custom_id":"request-12","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How do self-driving cars use AI to navigate and make decisions?"}],"max_tokens":150}}
{"custom_id":"request-13","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What is the difference between strong AI and weak AI?"}],"max_tokens":150}}
{"custom_id":"request-14","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How do language models handle context and maintain coherence in long texts?"}],"max_tokens":150}}
{"custom_id":"request-15","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What are some applications of AI in healthcare?"}],"max_tokens":150}}
{"custom_id":"request-16","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How does federated learning protect user privacy in AI systems?"}],"max_tokens":150}}
{"custom_id":"request-17","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What is the role of bias in AI, and how can it be mitigated?"}],"max_tokens":150}}
{"custom_id":"request-18","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"Can you explain the concept of explainable AI (XAI)?"}],"max_tokens":150}}
{"custom_id":"request-19","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"How do recommendation systems use AI to personalize content?"}],"max_tokens":150}}
{"custom_id":"request-20","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-3.5-turbo-0125","messages":[{"role":"system","content":"You are a helpful AI assistant specializing in explaining AI and machine learning concepts."},{"role":"user","content":"What are the challenges in developing AI systems that can reason like humans?"}],"max_tokens":150}}
@@ -0,0 +1,12 @@
import BatchSubmissionForm from "@/components/BatchSubmissionForm";
import { auth } from "@trigger.dev/sdk/v3";
export default async function Page() {
const accessToken = await auth.createTriggerPublicToken("openai-batch");
return (
<div className="min-h-screen bg-gray-100 flex flex-col items-center justify-center p-4 space-y-8">
<BatchSubmissionForm accessToken={accessToken} />
</div>
);
}
@@ -0,0 +1,183 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckCircle2, Clock, FileText, Loader2, RefreshCw } from "lucide-react";
type BatchStatus = "validating" | "in_progress" | "completed" | "failed" | "expired";
interface BatchInfo {
id: string;
status: BatchStatus;
totalRequests: number;
completedRequests: number;
failedRequests: number;
inputFileName: string;
outputFileName: string | null;
errorFileName: string | null;
createdAt: string;
completedAt: string | null;
}
export default function BatchProgressIndicator() {
const [batchInfo, setBatchInfo] = useState<BatchInfo>({
id: "batch_abc123",
status: "in_progress",
totalRequests: 1000,
completedRequests: 750,
failedRequests: 10,
inputFileName: "input.jsonl",
outputFileName: null,
errorFileName: null,
createdAt: "2023-03-15T10:30:00Z",
completedAt: null,
});
const [lastCheckedAt, setLastCheckedAt] = useState<string>(new Date().toISOString());
useEffect(() => {
// Simulate progress
const interval = setInterval(() => {
setBatchInfo((prev) => ({
...prev,
completedRequests: Math.min(prev.completedRequests + 10, prev.totalRequests),
status: prev.completedRequests + 10 >= prev.totalRequests ? "completed" : prev.status,
completedAt:
prev.completedRequests + 10 >= prev.totalRequests ? new Date().toISOString() : null,
outputFileName: prev.completedRequests + 10 >= prev.totalRequests ? "output.jsonl" : null,
}));
setLastCheckedAt(new Date().toISOString());
}, 1000);
return () => clearInterval(interval);
}, []);
const getStatusIcon = (status: BatchStatus) => {
switch (status) {
case "validating":
case "in_progress":
return <Loader2 className="w-4 h-4 animate-spin" />;
case "completed":
return <CheckCircle2 className="w-4 h-4 text-green-600" />;
case "failed":
return <AlertCircle className="w-4 h-4 text-red-600" />;
case "expired":
return <Clock className="w-4 h-4 text-yellow-600" />;
}
};
const getStatusColor = (status: BatchStatus) => {
switch (status) {
case "validating":
case "in_progress":
return "bg-blue-100 text-blue-800 border-blue-300";
case "completed":
return "bg-green-100 text-green-800 border-green-300";
case "failed":
return "bg-red-100 text-red-800 border-red-300";
case "expired":
return "bg-yellow-100 text-yellow-800 border-yellow-300";
}
};
return (
<Card className="w-full max-w-2xl bg-white text-gray-800 border border-gray-200 shadow-sm font-mono">
<CardHeader className="border-b border-gray-200">
<CardTitle className="flex items-center justify-between text-lg">
<span className="font-bold">Batch Progress: {batchInfo.id}</span>
<Badge
variant="outline"
className={`${getStatusColor(
batchInfo.status
)} px-2 py-1 text-xs font-semibold rounded border`}
>
{getStatusIcon(batchInfo.status)}
<span className="ml-2 capitalize">{batchInfo.status.replace("_", " ")}</span>
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span className="font-bold">
{Math.round((batchInfo.completedRequests / batchInfo.totalRequests) * 100)}%
</span>
</div>
<Progress
value={(batchInfo.completedRequests / batchInfo.totalRequests) * 100}
className="h-2 bg-gray-200"
/>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-gray-50 p-2 rounded">
<p className="text-gray-500">Total Requests</p>
<p className="font-bold">{batchInfo.totalRequests}</p>
</div>
<div className="bg-gray-50 p-2 rounded">
<p className="text-gray-500">Completed</p>
<p className="font-bold">{batchInfo.completedRequests}</p>
</div>
<div className="bg-gray-50 p-2 rounded">
<p className="text-gray-500">Failed</p>
<p className="font-bold">{batchInfo.failedRequests}</p>
</div>
<div className="bg-gray-50 p-2 rounded">
<p className="text-gray-500">Created At</p>
<p className="font-bold">{new Date(batchInfo.createdAt).toLocaleString()}</p>
</div>
<div className="bg-gray-50 p-2 rounded col-span-2">
<p className="text-gray-500">Last Checked At</p>
<p className="font-bold">{new Date(lastCheckedAt).toLocaleString()}</p>
</div>
</div>
<div className="space-y-2 bg-gray-50 p-2 rounded">
<div className="flex items-center space-x-2">
<FileText className="w-4 h-4 text-gray-500" />
<span className="text-sm">
Input: <span className="font-bold">{batchInfo.inputFileName}</span>
</span>
</div>
{batchInfo.outputFileName && (
<div className="flex items-center space-x-2">
<FileText className="w-4 h-4 text-gray-500" />
<span className="text-sm">
Output: <span className="font-bold">{batchInfo.outputFileName}</span>
</span>
</div>
)}
{batchInfo.errorFileName && (
<div className="flex items-center space-x-2">
<FileText className="w-4 h-4 text-gray-500" />
<span className="text-sm">
Errors: <span className="font-bold">{batchInfo.errorFileName}</span>
</span>
</div>
)}
</div>
<div className="flex justify-end space-x-2">
<Button
variant="outline"
size="sm"
className="text-blue-600 border-blue-300 hover:bg-blue-50"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button
variant="destructive"
size="sm"
className="bg-red-100 text-red-600 hover:bg-red-200 border border-red-300"
disabled={
batchInfo.status === "completed" ||
batchInfo.status === "failed" ||
batchInfo.status === "expired"
}
>
Cancel Batch
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Upload, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useRealtimeRun, useTaskTrigger } from "@trigger.dev/react-hooks";
import type { openaiBatch } from "@/trigger/openaiBatch";
export default function BatchSubmissionForm({ accessToken }: { accessToken: string }) {
const trigger = useTaskTrigger<typeof openaiBatch>("openai-batch", {
accessToken,
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
});
const { run } = useRealtimeRun<typeof openaiBatch>(trigger.handle?.id, {
accessToken: trigger.handle?.publicAccessToken,
enabled: !!trigger.handle,
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
});
const [jsonlContent, setJsonlContent] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
trigger.submit({
jsonl: jsonlContent,
});
};
return (
<Card className="w-full max-w-2xl bg-white text-gray-800 border border-gray-200 shadow-sm font-mono">
<CardHeader className="border-b border-gray-200">
<CardTitle className="text-lg font-bold">Submit Batch Job</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="jsonl-input" className="block text-sm font-medium text-gray-700 mb-1">
JSONL Content
</label>
<Textarea
id="jsonl-input"
value={jsonlContent}
onChange={(e) => setJsonlContent(e.target.value)}
placeholder="Paste your JSONL content here..."
className="w-full h-48 p-2 text-sm bg-gray-50 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{trigger.error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{trigger.error.message}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={trigger.isLoading || !jsonlContent.trim()}
className="bg-blue-600 text-white hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center"
>
{trigger.isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Submitting...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
Submit Batch
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
@@ -8,7 +8,7 @@ export const handleCSVUpload = schemaTask({
id: "handle-csv-upload",
schema: UploadedFileData,
run: async (file, { ctx }) => {
logger.info("Handling uploaded file", { file });
logger.info("Handling uploaded files", { file });
metadata.set("status", "fetching");
@@ -79,7 +79,7 @@ export const handleCSVRow = schemaTask({
logger.info("Handling CSV row", { row });
// Simulate processing time
await setTimeout(200 + Math.random() * 1000); // 200ms - 1.2s
await setTimeout(200 + Math.random() * 1012); // 200ms - 1.2s
metadata.parent.increment("processedRows", 1).append("rowRuns", ctx.run.id);
@@ -0,0 +1,74 @@
import { logger, schemaTask, wait } from "@trigger.dev/sdk/v3";
import { createReadStream, writeFileSync } from "node:fs";
import OpenAI from "openai";
import { z } from "zod";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export const openaiBatch = schemaTask({
id: "openai-batch",
description: "Run a batch of JSONL prompts through OpenAI",
schema: z.object({
jsonl: z.string(),
}),
run: async ({ jsonl }) => {
// Write a JSONL file to disk
writeFileSync("batchinput.jsonl", jsonl);
const file = await openai.files.create({
file: createReadStream("batchinput.jsonl"),
purpose: "batch",
});
logger.log("Created file", { file });
const batch = await openai.batches.create({
input_file_id: file.id,
endpoint: "/v1/chat/completions",
completion_window: "24h",
});
logger.log("Created batch", { batch });
const completedBatch = await openaiBatchMonitor
.triggerAndWait({
batchId: batch.id,
})
.unwrap();
return completedBatch;
},
});
export const openaiBatchMonitor = schemaTask({
id: "openai-batch-monitor",
description: "Monitor the status of an OpenAI batch job",
schema: z.object({
batchId: z.string(),
}),
run: async ({ batchId }) => {
logger.log("Monitoring batch", { batchId });
while (true) {
const batch = await openai.batches.retrieve(batchId);
logger.log("Batch status", { batch });
if (
batch.status === "failed" ||
batch.status === "completed" ||
batch.status === "expired" ||
batch.status === "cancelled"
) {
logger.log("Batch completed", { batch });
return batch;
}
// Check every 10 seconds
await wait.for({ seconds: 10 });
}
},
});