Merge remote-tracking branch 'origin/main' into feat/compute-workload-manager

This commit is contained in:
nicktrn
2026-03-06 20:11:09 +00:00
12 changed files with 1255 additions and 298 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Add sidebar tabs (Options, AI, Schema) to the Test page for schemaTask payload generation and schema viewing.
@@ -6,6 +6,7 @@ import {
type TaskRunTemplate,
PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { inferSchema } from "@jsonhero/schema-infer";
import parse from "parse-duration";
import { type PrismaClient } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
@@ -34,6 +35,8 @@ type Task = {
taskIdentifier: string;
filePath: string;
friendlyId: string;
payloadSchema?: unknown;
inferredPayloadSchema?: unknown;
};
type Queue = {
@@ -244,11 +247,30 @@ export class TestTaskPresenter {
},
});
// Infer schema from existing run payloads when no explicit schema is defined
let inferredPayloadSchema: unknown | undefined;
if (!task.payloadSchema && latestRuns.length > 0 && task.triggerSource === "STANDARD") {
let inference: ReturnType<typeof inferSchema> | undefined;
for (const run of latestRuns) {
try {
const parsed = await parsePacket({ data: run.payload, dataType: run.payloadType });
inference = inferSchema(parsed, inference);
} catch {
// Skip malformed runs — inference is best-effort
}
}
if (inference) {
inferredPayloadSchema = inference.toJSONSchema();
}
}
const taskWithEnvironment = {
id: task.id,
taskIdentifier: task.slug,
filePath: task.filePath,
friendlyId: task.friendlyId,
payloadSchema: task.payloadSchema ?? undefined,
inferredPayloadSchema,
};
switch (task.triggerSource) {
@@ -0,0 +1,377 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { Button } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
type StreamEventType =
| { type: "thinking"; content: string }
| { type: "result"; success: true; payload: string }
| { type: "result"; success: false; error: string };
export function AIPayloadTabContent({
onPayloadGenerated,
payloadSchema,
taskIdentifier,
getCurrentPayload,
}: {
onPayloadGenerated: (payload: string) => void;
payloadSchema?: unknown;
taskIdentifier: string;
getCurrentPayload?: () => string;
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const isLoadingRef = useRef(false);
const [thinking, setThinking] = useState("");
const [error, setError] = useState<string | null>(null);
const [showThinking, setShowThinking] = useState(false);
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/test/ai-generate-payload`;
const submitGeneration = useCallback(
async (queryPrompt: string) => {
if (!queryPrompt.trim() || isLoadingRef.current) return;
isLoadingRef.current = true;
setIsLoading(true);
setThinking("");
setError(null);
setShowThinking(true);
setLastResult(null);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const formData = new FormData();
formData.append("prompt", queryPrompt);
formData.append("taskIdentifier", taskIdentifier);
if (payloadSchema) {
formData.append("payloadSchema", JSON.stringify(payloadSchema));
}
const currentPayload = getCurrentPayload?.();
if (currentPayload) {
formData.append("currentPayload", currentPayload);
}
const response = await fetch(resourcePath, {
method: "POST",
body: formData,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
setError(errorData.error || "Failed to generate payload");
setIsLoading(false);
setLastResult("error");
return;
}
const reader = response.body?.getReader();
if (!reader) {
setError("No response stream");
setIsLoading(false);
setLastResult("error");
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
}
}
if (buffer.startsWith("data: ")) {
try {
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "An error occurred");
setLastResult("error");
} finally {
isLoadingRef.current = false;
setIsLoading(false);
}
},
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "result":
if (event.success) {
onPayloadGenerated(event.payload);
setPrompt("");
setLastResult("success");
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onPayloadGenerated]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
submitGeneration(prompt);
},
[prompt, submitGeneration]
);
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 15000);
return () => clearTimeout(timer);
}
}, [error]);
const examplePrompts = payloadSchema
? [
"Generate a valid payload",
"Generate a payload with edge cases",
"Generate a minimal payload with only required fields",
]
: [
"Generate a simple JSON payload",
"Generate a payload with nested objects",
"Generate a payload with an array of items",
];
return (
<div className="space-y-2">
<div
className="overflow-hidden rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-md bg-background-bright">
<div>
<textarea
ref={textareaRef}
name="prompt"
placeholder={
payloadSchema
? "e.g. generate a payload for a new user signup"
: "e.g. generate a JSON payload with name, email, and age fields"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isLoading}
rows={5}
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-2 px-2 pb-2">
{isLoading ? (
<Button
type="button"
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-2"
iconSpacing="gap-1.5"
>
Generating
</Button>
) : (
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim()}
className={cn(!prompt.trim() && "opacity-50")}
onClick={() => handleSubmit()}
>
Generate payload
</Button>
)}
</div>
</div>
</div>
</div>
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
{error}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Thinking panel */}
<AnimatePresence>
{showThinking && thinking && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-1">
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
{isLoading ? (
<Spinner className="size-4" />
) : lastResult === "success" ? (
<CheckIcon className="size-4 text-success" />
) : lastResult === "error" ? (
<XMarkIcon className="size-4 text-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking…"
: lastResult === "success"
? "Payload generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Example prompts */}
<div className="pt-4">
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
type="button"
disabled={isLoading}
onClick={() => {
setPrompt(example);
submitGeneration(example);
}}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:!rounded-full disabled:cursor-not-allowed disabled:opacity-50"
>
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph
variant="small"
className="text-left transition group-hover:text-text-bright"
>
{example}
</Paragraph>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { CodeBlock } from "~/components/code/CodeBlock";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { docsPath } from "~/utils/pathBuilder";
export function SchemaTabContent({
schema,
inferredSchema,
}: {
schema?: unknown;
inferredSchema?: unknown;
}) {
if (schema) {
return (
<div className="space-y-2">
<Header3 className="text-text-bright">Payload schema</Header3>
<Paragraph variant="extra-small" className="text-text-dimmed">
JSON Schema defined by this task via{" "}
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
</Paragraph>
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
showLineNumbers={false}
showOpenInModal={false}
/>
</div>
);
}
if (inferredSchema) {
return (
<div className="space-y-3">
<Header3 className="text-text-bright">Inferred schema</Header3>
<Paragraph variant="extra-small" className="text-text-dimmed">
Schema inferred from recent run payloads. For an exact schema, use schemaTask.
</Paragraph>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("tasks/schemaTask")}
>
schemaTask docs
</LinkButton>
<CodeBlock
code={JSON.stringify(inferredSchema, null, 2)}
language="json"
showLineNumbers={false}
showOpenInModal={false}
/>
</div>
);
}
return (
<div className="space-y-3">
<Header3 className="text-text-bright">No schema defined</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Use <code className="text-text-bright">schemaTask</code> to define a payload schema for this
task. The schema will appear here and can be used by AI to generate example payloads.
</Paragraph>
<LinkButton variant="docs/small" LeadingIcon={BookOpenIcon} to={docsPath("tasks/schemaTask")}>
schemaTask docs
</LinkButton>
<CodeBlock
code={exampleCode}
language="typescript"
showLineNumbers={false}
showCopyButton={false}
showOpenInModal={false}
/>
</div>
);
}
const exampleCode = `import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const myTask = schemaTask({
id: "my-task",
schema: z.object({
name: z.string(),
email: z.string().email(),
count: z.number().int().positive(),
}),
run: async (payload) => {
// payload is fully typed
console.log(payload.name);
},
});`;
@@ -0,0 +1,78 @@
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
export function TestSidebarTabs({
activeTab,
onTabChange,
optionsContent,
aiContent,
schemaContent,
}: {
activeTab: string;
onTabChange: (tab: string) => void;
optionsContent: React.ReactNode;
aiContent: React.ReactNode;
schemaContent: React.ReactNode;
}) {
return (
<ClientTabs
value={activeTab}
onValueChange={onTabChange}
className="flex h-full min-h-0 flex-col overflow-hidden pt-1"
>
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<ClientTabsList variant="underline" className="mx-3 shrink-0">
<ClientTabsTrigger
value="options"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
Options
</ClientTabsTrigger>
<ClientTabsTrigger
value="ai"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
<span className="flex items-center gap-0.5">
<AISparkleIcon className="size-4" /> AI
</span>
</ClientTabsTrigger>
<ClientTabsTrigger
value="schema"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
Schema
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent
value="options"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
{optionsContent}
</ClientTabsContent>
<ClientTabsContent
value="ai"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<div className="min-w-64 p-3">{aiContent}</div>
</ClientTabsContent>
<ClientTabsContent
value="schema"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<div className="min-w-64 p-3">{schemaContent}</div>
</ClientTabsContent>
</ClientTabs>
);
}
@@ -76,6 +76,9 @@ import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
import { TestSidebarTabs } from "./TestSidebarTabs";
import { AIPayloadTabContent } from "./AIPayloadTabContent";
import { SchemaTabContent } from "./SchemaTabContent";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
@@ -398,6 +401,7 @@ function StandardTaskForm({
lastRun?.maxDurationInSeconds
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const [sidebarTab, setSidebarTab] = useState("options");
const regionItems = regions.map((r) => ({
value: r.name,
@@ -547,274 +551,298 @@ function StandardTaskForm({
</div>
</ResizablePanel>
<ResizableHandle id="test-task-handle" />
<ResizablePanel id="test-task-options" min="300px" default="300px" max="360px">
<div className="h-full overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Fieldset className="px-3 py-3">
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
<Select
{...conform.select(machine)}
variant="tertiary/small"
placeholder="Select machine type"
dropdownIcon
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setMachineValue(e);
}}
>
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
disabled={disableVersionSelection}
>
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>Only the latest version is available in the development environment.</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<ResizablePanel id="test-task-options" min="300px" default="380px" max="600px">
<TestSidebarTabs
activeTab={sidebarTab}
onTabChange={setSidebarTab}
optionsContent={
<Fieldset className="px-3 py-3">
<Hint>
Options enable you to control the execution behavior of your task.{" "}
<TextLink to={docsPath("triggering#options")}>Read the docs.</TextLink>
</Hint>
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
<Label htmlFor={machine.id} variant="small">
Machine
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
{...conform.select(machine)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
placeholder="Select machine type"
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
items={machinePresets}
defaultValue={undefined}
value={machineValue}
setValue={(e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
setMachineValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
{machinePresets.map((machine) => (
<SelectItem key={machine} value={machine}>
{machine}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
<Hint>Overrides the machine preset.</Hint>
<FormError id={machine.errorId}>{machine.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{allowArbitraryQueues ? (
<Input
{...conform.input(queue, { type: "text" })}
variant="small"
value={queueValue ?? ""}
onChange={(e) => setQueueValue(e.target.value)}
/>
) : (
<InputGroup>
<Label htmlFor={version.id} variant="small">
Version
</Label>
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
{...conform.select(version)}
defaultValue="latest"
variant="tertiary/small"
placeholder="Select version"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
value={queueValue}
setValue={setQueueValue}
disabled={disableVersionSelection}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-[var(--popover-anchor-width)]"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
{versions.map((version, i) => (
<SelectItem key={version} value={i === 0 ? "latest" : version}>
{version} {i === 0 && "(latest)"}
</SelectItem>
))}
</Select>
{disableVersionSelection ? (
<Hint>
Only the latest version is available in the development environment.
</Hint>
) : (
<Hint>Runs task on a specific version.</Hint>
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={
isDev
? undefined
: (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}
}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
tags={tagsValue}
onTagsChange={setTagsValue}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...conform.input(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
value={maxAttemptsValue}
onChange={(e) =>
setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined)
}
onKeyDown={(e) => {
// only allow entering integers > 1
if (["-", "+", ".", "e", "E"].includes(e.key)) {
e.preventDefault();
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
</Label>
{allowArbitraryQueues ? (
<Input
{...conform.input(queue, { type: "text" })}
variant="small"
value={queueValue ?? ""}
onChange={(e) => setQueueValue(e.target.value)}
/>
) : (
<Select
name={queue.name}
id={queue.id}
placeholder="Select queue"
heading="Filter queues"
variant="tertiary/small"
dropdownIcon
items={queueItems}
filter={{ keys: ["label"] }}
value={queueValue}
setValue={setQueueValue}
>
{(matches) =>
matches.map((queueItem) => (
<SelectItem
key={queueItem.value}
value={queueItem.value}
className="max-w-[var(--popover-anchor-width)]"
icon={
queueItem.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
<div className="flex w-full min-w-0 items-center justify-between">
<span className="truncate">{queueItem.label}</span>
{queueItem.paused && (
<Badge variant="extra-small" className="ml-1 text-warning">
Paused
</Badge>
)}
</div>
</SelectItem>
))
}
</Select>
)}
<Hint>Assign run to a specific queue.</Hint>
<FormError id={queue.errorId}>{queue.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={tags.id} variant="small">
Tags
</Label>
<RunTagInput
name={tags.name}
id={tags.id}
variant="small"
tags={tagsValue}
onTagsChange={setTagsValue}
/>
<Hint>Add tags to easily filter runs.</Hint>
<FormError id={tags.errorId}>{tags.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={maxAttempts.id} variant="small">
Max attempts
</Label>
<Input
{...conform.input(maxAttempts, { type: "number" })}
className="[&::-webkit-inner-spin-button]:appearance-none"
variant="small"
min={1}
value={maxAttemptsValue}
onChange={(e) =>
setMaxAttemptsValue(e.target.value ? parseInt(e.target.value) : undefined)
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value < 1 && e.target.value !== "") {
e.target.value = "1";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Max duration</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
value={maxDurationValue}
onChange={setMaxDurationValue}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...conform.input(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.error}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label variant="small">Idempotency key TTL</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.error}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...conform.input(concurrencyKey, { type: "text" })}
variant="small"
value={concurrencyKeyValue ?? ""}
onChange={(e) => setConcurrencyKeyValue(e.target.value)}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
</Fieldset>
</div>
onKeyDown={(e) => {
// only allow entering integers > 1
if (["-", "+", ".", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (value < 1 && e.target.value !== "") {
e.target.value = "1";
}
}}
/>
<Hint>Retries failed runs up to the specified number of attempts.</Hint>
<FormError id={maxAttempts.errorId}>{maxAttempts.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Max duration</Label>
<DurationPicker
name={maxDurationSeconds.name}
id={maxDurationSeconds.id}
value={maxDurationValue}
onChange={setMaxDurationValue}
/>
<Hint>Overrides the maximum compute time limit for the run.</Hint>
<FormError id={maxDurationSeconds.errorId}>{maxDurationSeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={idempotencyKey.id} variant="small">
Idempotency key
</Label>
<Input {...conform.input(idempotencyKey, { type: "text" })} variant="small" />
<FormError id={idempotencyKey.errorId}>{idempotencyKey.error}</FormError>
<Hint>
Specify an idempotency key to ensure that a task is only triggered once with the
same key.
</Hint>
</InputGroup>
<InputGroup>
<Label variant="small">Idempotency key TTL</Label>
<DurationPicker
name={idempotencyKeyTTLSeconds.name}
id={idempotencyKeyTTLSeconds.id}
/>
<Hint>Keys expire after 30 days by default.</Hint>
<FormError id={idempotencyKeyTTLSeconds.errorId}>
{idempotencyKeyTTLSeconds.error}
</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={concurrencyKey.id} variant="small">
Concurrency key
</Label>
<Input
{...conform.input(concurrencyKey, { type: "text" })}
variant="small"
value={concurrencyKeyValue ?? ""}
onChange={(e) => setConcurrencyKeyValue(e.target.value)}
/>
<Hint>
Limits concurrency by creating a separate queue for each value of the key.
</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Delay</Label>
<DurationPicker name={delaySeconds.name} id={delaySeconds.id} />
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
name={ttlSeconds.name}
id={ttlSeconds.id}
value={ttlValue}
onChange={setTtlValue}
/>
<Hint>Expires the run if it hasn't started within the TTL.</Hint>
<FormError id={ttlSeconds.errorId}>{ttlSeconds.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
</Fieldset>
}
aiContent={
<AIPayloadTabContent
onPayloadGenerated={setPayload}
payloadSchema={task.payloadSchema ?? task.inferredPayloadSchema}
taskIdentifier={task.taskIdentifier}
getCurrentPayload={() => currentPayloadJson.current}
/>
}
schemaContent={
<SchemaTabContent
schema={task.payloadSchema}
inferredSchema={task.inferredPayloadSchema}
/>
}
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-3 border-t border-grid-bright bg-background-dimmed p-2">
@@ -1198,10 +1226,14 @@ function ScheduledTaskForm({
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
setValue={
isDev
? undefined
: (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}
}
disabled={isDev}
>
{regionItems.map((r) => (
@@ -0,0 +1,312 @@
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { inflate } from "node:zlib";
import { promisify } from "node:util";
const inflateAsync = promisify(inflate);
import { $replica } from "~/db.server";
import { logger } from "~/services/logger.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
const RequestSchema = z.object({
prompt: z.string().min(1, "Prompt is required").max(1000),
taskIdentifier: z.string().max(256),
payloadSchema: z.string().max(50_000).optional(),
currentPayload: z.string().max(50_000).optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const formData = await request.formData();
const submission = RequestSchema.safeParse(Object.fromEntries(formData));
if (!submission.success) {
return new Response(
JSON.stringify({ type: "result", success: false, error: "Invalid request data" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return new Response(
JSON.stringify({ type: "result", success: false, error: "Project not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return new Response(
JSON.stringify({ type: "result", success: false, error: "Environment not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
if (!env.OPENAI_API_KEY) {
return new Response(
JSON.stringify({
type: "result",
success: false,
error: "OpenAI API key is not configured",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const { prompt, taskIdentifier, payloadSchema, currentPayload } = submission.data;
logger.info("[AI payload] Generating payload", {
taskIdentifier,
hasPayloadSchema: !!payloadSchema,
hasCurrentPayload: !!currentPayload,
promptLength: prompt.length,
});
const systemPrompt = buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload);
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (event: {
type: string;
content?: string;
success?: boolean;
payload?: string;
error?: string;
}) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
try {
const result = streamText({
model: openai(env.AI_RUN_FILTER_MODEL ?? "gpt-5-mini"),
temperature: 1,
abortSignal: request.signal,
system: systemPrompt,
prompt,
tools: {
getTaskSourceCode: tool({
description:
"Look up the source code of the task to understand what payload shape it expects. Use this when there is no JSON Schema available and you need to infer the payload structure from the task implementation.",
parameters: z.object({}),
execute: async () => {
return getTaskSourceCode(environment.id, environment.type, taskIdentifier);
},
}),
},
maxSteps: 3,
});
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta": {
sendEvent({ type: "thinking", content: part.textDelta });
break;
}
case "tool-call": {
sendEvent({
type: "thinking",
content: "\n\nLooking up task source code...\n\n",
});
break;
}
case "error": {
sendEvent({
type: "result",
success: false,
error: part.error instanceof Error ? part.error.message : String(part.error),
});
break;
}
}
}
// Extract JSON from the final aggregated text (across all steps)
const finalText = await result.text;
const payload = extractJsonFromText(finalText);
if (payload) {
sendEvent({ type: "result", success: true, payload });
} else {
sendEvent({
type: "result",
success: false,
error: "Could not generate a valid JSON payload",
});
}
} catch (error) {
sendEvent({
type: "result",
success: false,
error: error instanceof Error ? error.message : "An error occurred",
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async function getTaskSourceCode(
environmentId: string,
environmentType: string,
taskIdentifier: string
): Promise<string> {
try {
logger.info("[AI payload] Looking up task source code", {
taskIdentifier,
environmentId,
environmentType,
});
const task =
environmentType !== "DEVELOPMENT"
? await getTaskFromDeployment(environmentId, taskIdentifier)
: await $replica.backgroundWorkerTask.findFirst({
where: { slug: taskIdentifier, runtimeEnvironmentId: environmentId },
orderBy: { createdAt: "desc" },
select: { fileId: true },
});
if (!task?.fileId) {
logger.info("[AI payload] No fileId found for task", { taskIdentifier });
return "Source code not available for this task.";
}
const file = await $replica.backgroundWorkerFile.findUnique({
where: { id: task.fileId },
select: { contents: true, filePath: true },
});
if (!file) {
logger.info("[AI payload] File record not found", { taskIdentifier, fileId: task.fileId });
return "Source code not available for this task.";
}
// File contents are zlib-deflated then base64-encoded by the CLI,
// stored as Buffer.from(base64String) in Prisma Bytes
const base64 = Buffer.from(file.contents).toString("utf-8");
const decompressed = (await inflateAsync(Buffer.from(base64, "base64"))).toString("utf-8");
logger.info("[AI payload] Found task source code", {
taskIdentifier,
filePath: file.filePath,
contentLength: decompressed.length,
});
return `File: ${file.filePath}\n\n${decompressed}`;
} catch (error) {
logger.error("[AI payload] Failed to retrieve task source code", {
taskIdentifier,
error: error instanceof Error ? error.message : String(error),
});
return "Failed to retrieve task source code.";
}
}
async function getTaskFromDeployment(environmentId: string, taskIdentifier: string) {
const deployment = await findCurrentWorkerDeployment({ environmentId });
if (!deployment?.worker) return null;
const task = deployment.worker.tasks.find((t) => t.slug === taskIdentifier);
if (!task) return null;
return { fileId: task.fileId };
}
function buildSystemPrompt(
taskIdentifier: string,
payloadSchema?: string,
currentPayload?: string
): string {
let prompt = `You are a JSON payload generator for a Trigger.dev task with id "${taskIdentifier}".
Your job is to generate a valid JSON payload that can be used to test this task. Return ONLY valid JSON wrapped in a \`\`\`json code block. Do not include any explanation outside the code block.
Requirements:
- Generate realistic, meaningful example data
- All string values should be plausible (real-looking names, emails, URLs, etc.)
- Number values should be reasonable for their context
- The JSON must be valid and parseable`;
if (payloadSchema) {
prompt += `
The task has the following JSON Schema that the payload must conform to:
\`\`\`json
${payloadSchema}
\`\`\`
Generate a payload that strictly conforms to this schema, respecting all type constraints, required fields, enums, formats, and validation rules.`;
} else {
prompt += `
No JSON Schema is available for this task. Use the getTaskSourceCode tool to look up the task's source code file.
IMPORTANT instructions for reading the source code:
- The file may contain multiple task definitions. Find the one with id "${taskIdentifier}".
- Look at the \`run\` function's payload parameter type to determine the expected shape.
- If the payload is typed as \`any\`, \`unknown\`, or has no type annotation, check how payload properties are actually accessed inside the \`run\` function body to infer the structure.
- If the payload type is explicitly defined (e.g. \`{ name: string, count: number }\`), use that exactly.
- If the payload is typed as \`any\` and is never accessed or destructured in the function body, the task likely accepts any payload. In that case generate a simple \`{}\` empty object.
- Do NOT invent complex payload structures that aren't supported by the code. Only include fields you can confirm from the type annotation or actual usage in the function body.`;
}
if (currentPayload) {
prompt += `
The current payload in the editor is:
\`\`\`json
${currentPayload}
\`\`\`
Use this as context for what the user might want, but generate a new payload based on the user's prompt.`;
}
return prompt;
}
function extractJsonFromText(text: string): string | null {
// Try to extract from code block first
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (codeBlockMatch) {
const candidate = codeBlockMatch[1].trim();
try {
// Validate and pretty-print
return JSON.stringify(JSON.parse(candidate), null, 2);
} catch {
// Fall through
}
}
// Try to find a JSON object or array
const jsonMatch = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (jsonMatch) {
try {
return JSON.stringify(JSON.parse(jsonMatch[1]), null, 2);
} catch {
// Fall through
}
}
return null;
}
@@ -55,6 +55,8 @@ type WorkerDeploymentWithWorkerTasks = Prisma.WorkerDeploymentGetPayload<{
maxDurationInSeconds: true;
queueConfig: true;
queueId: true;
payloadSchema: true;
fileId: true;
};
};
};
+1
View File
@@ -54,6 +54,7 @@
"@electric-sql/react": "^0.3.5",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.12",
"@jsonhero/schema-infer": "^0.1.5",
"@internal/cache": "workspace:*",
"@internal/redis": "workspace:*",
"@internal/run-engine": "workspace:*",
@@ -279,6 +279,9 @@ export function getLogsSearchListQueryBuilder(ch: ClickhouseReader) {
"attributes_text",
"triggered_timestamp",
],
settings: {
use_query_condition_cache: 1,
},
});
}
+60
View File
@@ -320,6 +320,9 @@ importers:
'@internationalized/date':
specifier: ^3.5.1
version: 3.5.1
'@jsonhero/schema-infer':
specifier: ^0.1.5
version: 0.1.5
'@kapaai/react-sdk':
specifier: ^0.1.3
version: 0.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -5818,9 +5821,22 @@ packages:
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@jsonhero/json-infer-types@1.2.11':
resolution: {integrity: sha512-Bg9FFi4AlqaR29a32GsPxYWehVjtuXJ8cilMrcQA1lqaNZdt0TW5od0T7JeUxkRvxzck+QaXz4EvTA6mP/PVaw==}
engines: {node: '16'}
'@jsonhero/json-schema-fns@0.0.1':
resolution: {integrity: sha512-9/ykTgok+9yAQpYwqFI7yvDDGfN36uZEEVzUgLFktT/lQcDERX0NsRZsb1PPurnU3NortUK4XhnYKMu4cRMH2Q==}
engines: {node: '16'}
'@jsonhero/path@1.0.21':
resolution: {integrity: sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q==}
'@jsonhero/schema-infer@0.1.5':
resolution: {integrity: sha512-iZjrlRJ3JiIYbK77OSYvnp/fj7b4/FtahxEF/lZO0+4DZWjwOMdH5Apd41Nc8lMgvhuB4hGIsLec0J+RAOy6Tg==}
engines: {node: '>=16'}
hasBin: true
'@jspm/core@2.0.1':
resolution: {integrity: sha512-Lg3PnLp0QXpxwLIAuuJboLeRaIhrgJjeuh797QADg3xz8wGLugQOS5DpsE8A6i6Adgzf+bacllkKZG3J0tGfDw==}
@@ -14648,6 +14664,10 @@ packages:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
ip-address@8.1.0:
resolution: {integrity: sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==}
engines: {node: '>= 12'}
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -15122,6 +15142,9 @@ packages:
jws@3.2.3:
resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==}
jwt-decode@3.1.2:
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
katex@0.16.25:
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
hasBin: true
@@ -18566,6 +18589,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sprintf-js@1.1.2:
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
@@ -19223,6 +19249,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
ts-pattern@3.3.5:
resolution: {integrity: sha512-LD+wFR/RNckk1DrKV0LTH4KIT9wRqnnOjtEf77ovhKcVi8gf83Uf6U7OdywEua6KD9SbHadUdfolayfIUiPxzw==}
ts-poet@6.6.0:
resolution: {integrity: sha512-4vEH/wkhcjRPFOdBwIh9ItO6jOoumVLRF4aABDX5JSNEubSqwOulihxQPqai+OkuygJm3WYMInxXQX4QwVNMuw==}
@@ -24191,8 +24220,28 @@ snapshots:
dependencies:
jsep: 1.4.0
'@jsonhero/json-infer-types@1.2.11':
dependencies:
ip-address: 8.1.0
json5: 2.2.3
jwt-decode: 3.1.2
uuid: 8.3.2
'@jsonhero/json-schema-fns@0.0.1':
dependencies:
deepmerge: 4.3.1
lodash.omit: 4.5.0
ts-pattern: 3.3.5
'@jsonhero/path@1.0.21': {}
'@jsonhero/schema-infer@0.1.5':
dependencies:
'@jsonhero/json-infer-types': 1.2.11
'@jsonhero/json-schema-fns': 0.0.1
lodash.omit: 4.5.0
ts-pattern: 3.3.5
'@jspm/core@2.0.1': {}
'@kapaai/react-sdk@0.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
@@ -35730,6 +35779,11 @@ snapshots:
ip-address@10.0.1: {}
ip-address@8.1.0:
dependencies:
jsbn: 1.1.0
sprintf-js: 1.1.2
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
@@ -36151,6 +36205,8 @@ snapshots:
jwa: 1.4.2
safe-buffer: 5.2.1
jwt-decode@3.1.2: {}
katex@0.16.25:
dependencies:
commander: 8.3.0
@@ -40537,6 +40593,8 @@ snapshots:
sprintf-js@1.0.3: {}
sprintf-js@1.1.2: {}
sprintf-js@1.1.3: {}
sqids@0.3.0: {}
@@ -41345,6 +41403,8 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-pattern@3.3.5: {}
ts-poet@6.6.0:
dependencies:
dprint-node: 1.0.8
+16 -45
View File
@@ -100,13 +100,7 @@ function gitExec(args) {
async function getCommitForFile(filePath) {
try {
// Find the commit that added this file
const sha = await gitExec([
"log",
"--diff-filter=A",
"--format=%H",
"--",
filePath,
]);
const sha = await gitExec(["log", "--diff-filter=A", "--format=%H", "--", filePath]);
return sha.split("\n")[0] || null;
} catch {
return null;
@@ -118,15 +112,12 @@ async function getPrForCommit(commitSha) {
if (!token || !commitSha) return null;
try {
const res = await fetch(
`https://api.github.com/repos/${REPO}/commits/${commitSha}/pulls`,
{
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
}
);
const res = await fetch(`https://api.github.com/repos/${REPO}/commits/${commitSha}/pulls`, {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
});
if (!res.ok) return null;
const pulls = await res.json();
@@ -173,9 +164,7 @@ async function parseServerChanges() {
}
// Look up commits for all files in parallel
const commits = await Promise.all(
fileData.map((f) => getCommitForFile(f.filePath))
);
const commits = await Promise.all(fileData.map((f) => getCommitForFile(f.filePath)));
// Look up PRs for all commits in parallel
const prNumbers = await Promise.all(commits.map((sha) => getPrForCommit(sha)));
@@ -222,35 +211,24 @@ function formatPrBody({ version, packageEntries, serverEntries, rawBody }) {
const features = packageEntries.filter((e) => e.type === "feature");
const fixes = packageEntries.filter((e) => e.type === "fix");
const improvements = packageEntries.filter(
(e) => e.type === "improvement" || e.type === "other"
);
const improvements = packageEntries.filter((e) => e.type === "improvement" || e.type === "other");
const breaking = packageEntries.filter((e) => e.type === "breaking");
const serverFeatures = serverEntries.filter((e) => e.type === "feature");
const serverFixes = serverEntries.filter((e) => e.type === "fix");
const serverImprovements = serverEntries.filter(
(e) => e.type === "improvement"
);
const serverImprovements = serverEntries.filter((e) => e.type === "improvement");
const serverBreaking = serverEntries.filter((e) => e.type === "breaking");
const totalFeatures = features.length + serverFeatures.length;
const totalFixes = fixes.length + serverFixes.length;
const totalImprovements = improvements.length + serverImprovements.length;
lines.push(`# trigger.dev v${version}`);
lines.push("");
// Summary line
const parts = [];
if (totalFeatures > 0)
parts.push(`${totalFeatures} new feature${totalFeatures > 1 ? "s" : ""}`);
if (totalFeatures > 0) parts.push(`${totalFeatures} new feature${totalFeatures > 1 ? "s" : ""}`);
if (totalImprovements > 0)
parts.push(
`${totalImprovements} improvement${totalImprovements > 1 ? "s" : ""}`
);
if (totalFixes > 0)
parts.push(`${totalFixes} bug fix${totalFixes > 1 ? "es" : ""}`);
parts.push(`${totalImprovements} improvement${totalImprovements > 1 ? "s" : ""}`);
if (totalFixes > 0) parts.push(`${totalFixes} bug fix${totalFixes > 1 ? "es" : ""}`);
if (parts.length > 0) {
lines.push(`## Summary`);
lines.push(`${parts.join(", ")}.`);
@@ -260,8 +238,7 @@ function formatPrBody({ version, packageEntries, serverEntries, rawBody }) {
// Breaking changes
if (breaking.length > 0 || serverBreaking.length > 0) {
lines.push("## Breaking changes");
for (const entry of [...breaking, ...serverBreaking])
lines.push(`- ${entry.text}`);
for (const entry of [...breaking, ...serverBreaking]) lines.push(`- ${entry.text}`);
lines.push("");
}
@@ -290,17 +267,11 @@ function formatPrBody({ version, packageEntries, serverEntries, rawBody }) {
}
// Server changes
const allServer = [
...serverFeatures,
...serverImprovements,
...serverFixes,
];
const allServer = [...serverFeatures, ...serverImprovements, ...serverFixes];
if (allServer.length > 0) {
lines.push("## Server changes");
lines.push("");
lines.push(
"These changes affect the self-hosted Docker image and Trigger.dev Cloud:"
);
lines.push("These changes affect the self-hosted Docker image and Trigger.dev Cloud:");
lines.push("");
for (const entry of allServer) {
// Indent continuation lines so multi-line entries stay inside the list item