chat.task -> chat.agent
plus playground support, including playground conversations, and a new agent list
This commit is contained in:
@@ -240,6 +240,19 @@ export function BulkActionFilterSummary({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "sources": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
assertNever(typedKey);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
CpuChipIcon,
|
||||
CubeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
@@ -69,7 +70,9 @@ import {
|
||||
organizationTeamPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3AgentsPath,
|
||||
v3ApiKeysPath,
|
||||
v3PlaygroundPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
@@ -467,6 +470,22 @@ export function SideMenu({
|
||||
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
|
||||
onCollapseToggle={handleSectionToggle("ai")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Agents"
|
||||
icon={CpuChipIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
inactiveIconColor="text-indigo-500"
|
||||
to={v3AgentsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Playground"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-indigo-400"
|
||||
inactiveIconColor="text-indigo-400"
|
||||
to={v3PlaygroundPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Prompts"
|
||||
icon={AIPromptsIcon}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
RectangleStackIcon,
|
||||
Squares2X2Icon,
|
||||
@@ -184,6 +185,9 @@ export const TaskRunListSearchFilters = z.object({
|
||||
`Machine presets to filter by (${machines.join(", ")})`
|
||||
),
|
||||
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
|
||||
sources: StringOrStringArray.describe(
|
||||
"Task trigger sources to filter by (STANDARD, SCHEDULED, AGENT)"
|
||||
),
|
||||
});
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
@@ -225,6 +229,8 @@ export function filterTitle(filterKey: string) {
|
||||
return "Version";
|
||||
case "errorId":
|
||||
return "Error ID";
|
||||
case "sources":
|
||||
return "Source";
|
||||
default:
|
||||
return filterKey;
|
||||
}
|
||||
@@ -265,6 +271,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
|
||||
return <IconRotateClockwise2 className="size-4" />;
|
||||
case "errorId":
|
||||
return <IconBugFilled className="size-4" />;
|
||||
case "sources":
|
||||
return <CpuChipIcon className="size-4" />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -312,6 +320,10 @@ export function getRunFiltersFromSearchParams(
|
||||
? searchParams.getAll("versions")
|
||||
: undefined,
|
||||
errorId: searchParams.get("errorId") ?? undefined,
|
||||
sources:
|
||||
searchParams.getAll("sources").filter((v) => v.length > 0).length > 0
|
||||
? searchParams.getAll("sources")
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const parsed = TaskRunListSearchFilters.safeParse(params);
|
||||
@@ -353,7 +365,8 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
searchParams.has("queues") ||
|
||||
searchParams.has("machines") ||
|
||||
searchParams.has("versions") ||
|
||||
searchParams.has("errorId");
|
||||
searchParams.has("errorId") ||
|
||||
searchParams.has("sources");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
@@ -390,6 +403,7 @@ const filterTypes = [
|
||||
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
|
||||
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
|
||||
{ name: "source", title: "Source", icon: <CpuChipIcon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
@@ -445,6 +459,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
<AppliedScheduleIdFilter />
|
||||
<AppliedBulkActionsFilter bulkActions={bulkActions} />
|
||||
<AppliedErrorIdFilter />
|
||||
<AppliedSourceFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -483,6 +498,8 @@ function Menu(props: MenuProps) {
|
||||
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "error":
|
||||
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "source":
|
||||
return <SourceDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1896,3 +1913,101 @@ function AppliedErrorIdFilter() {
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const sourceOptions: { value: TaskTriggerSource; title: string }[] = [
|
||||
{ value: "STANDARD", title: "Standard" },
|
||||
{ value: "SCHEDULED", title: "Scheduled" },
|
||||
{ value: "AGENT", title: "Agent" },
|
||||
];
|
||||
|
||||
function SourceDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ sources: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return sourceOptions.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("sources")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by source..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.value} className="size-4 flex-none" />
|
||||
}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedSourceFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const sources = values("sources");
|
||||
|
||||
if (sources.length === 0 || sources.every((v) => v === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<SourceDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Source"
|
||||
icon={<CpuChipIcon className="size-4" />}
|
||||
value={appliedSummary(
|
||||
sources.map(
|
||||
(v) => sourceOptions.find((o) => o.value === v)?.title ?? v
|
||||
)
|
||||
)}
|
||||
onRemove={() => del(["sources", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,10 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -343,6 +345,10 @@ export function TaskRunsTable({
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-x-1">
|
||||
<TaskTriggerSourceIcon
|
||||
source={run.taskKind as TaskTriggerSource}
|
||||
className="size-3.5 flex-none"
|
||||
/>
|
||||
{run.taskIdentifier}
|
||||
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClockIcon } from "@heroicons/react/20/solid";
|
||||
import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({
|
||||
<ClockIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-schedules", className)} />
|
||||
);
|
||||
}
|
||||
case "AGENT": {
|
||||
return (
|
||||
<CpuChipIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-indigo-500", className)} />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
|
||||
case "SCHEDULED": {
|
||||
return "Scheduled task";
|
||||
}
|
||||
case "AGENT": {
|
||||
return "Agent";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) {
|
||||
|
||||
type ToolTab = "input" | "output" | "details";
|
||||
|
||||
function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
export function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
const hasInput = tool.inputJson !== "{}";
|
||||
const hasResult = !!tool.resultOutput;
|
||||
const hasDetails = !!tool.description || !!tool.parametersJson;
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function getRunFiltersFromRequest(request: Request): Promise<Filter
|
||||
queues,
|
||||
machines,
|
||||
errorId,
|
||||
sources,
|
||||
} = TaskRunListSearchFilters.parse(s);
|
||||
|
||||
return {
|
||||
@@ -56,5 +57,6 @@ export async function getRunFiltersFromRequest(request: Request): Promise<Filter
|
||||
queues,
|
||||
machines,
|
||||
errorId,
|
||||
sources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
type PrismaClientOrTransaction,
|
||||
type RuntimeEnvironmentType,
|
||||
type TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { ClickHouse } from "@internal/clickhouse";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
|
||||
export type AgentListItem = {
|
||||
slug: string;
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
config: unknown;
|
||||
};
|
||||
|
||||
export type AgentActiveState = {
|
||||
running: number;
|
||||
suspended: number;
|
||||
};
|
||||
|
||||
export class AgentListPresenter {
|
||||
constructor(
|
||||
private readonly clickhouse: ClickHouse,
|
||||
private readonly _replica: PrismaClientOrTransaction
|
||||
) {}
|
||||
|
||||
public async call({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
environmentType,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
}) {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{
|
||||
id: environmentId,
|
||||
type: environmentType,
|
||||
},
|
||||
this._replica
|
||||
);
|
||||
|
||||
if (!currentWorker) {
|
||||
return {
|
||||
agents: [],
|
||||
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
|
||||
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
costSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
};
|
||||
}
|
||||
|
||||
const agents = await this._replica.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
slug: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const slugs = agents.map((a) => a.slug);
|
||||
|
||||
if (slugs.length === 0) {
|
||||
return {
|
||||
agents,
|
||||
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
|
||||
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
costSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
};
|
||||
}
|
||||
|
||||
// All queries are deferred for streaming
|
||||
const activeStates = this.#getActiveStates(environmentId, slugs);
|
||||
const conversationSparklines = this.#getConversationSparklines(environmentId, slugs);
|
||||
const costSparklines = this.#getCostSparklines(environmentId, slugs);
|
||||
const tokenSparklines = this.#getTokenSparklines(environmentId, slugs);
|
||||
|
||||
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
|
||||
}
|
||||
|
||||
/** Count runs currently executing vs suspended per agent */
|
||||
async #getActiveStates(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, AgentActiveState>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentActiveStates",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
countIf(status = 'EXECUTING') AS running,
|
||||
countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND task_kind = 'AGENT'
|
||||
AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING')
|
||||
GROUP BY task_identifier`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
running: z.coerce.number(),
|
||||
suspended: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [error, rows] = await queryFn({ environmentId, slugs });
|
||||
if (error) {
|
||||
console.error("Agent active states query failed:", error);
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, AgentActiveState> = {};
|
||||
for (const row of rows) {
|
||||
result[row.task_identifier] = { running: row.running, suspended: row.suspended };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of conversation (run) count per agent */
|
||||
async #getConversationSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentConversationSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(created_at) AS bucket,
|
||||
count() AS val
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND task_kind = 'AGENT'
|
||||
AND created_at >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of LLM cost per agent */
|
||||
async #getCostSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentCostSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(start_time) AS bucket,
|
||||
sum(total_cost) AS val
|
||||
FROM trigger_dev.llm_metrics_v1
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND start_time >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of total tokens per agent */
|
||||
async #getTokenSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentTokenSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(start_time) AS bucket,
|
||||
sum(total_tokens) AS val
|
||||
FROM trigger_dev.llm_metrics_v1
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND start_time >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */
|
||||
#buildSparklineMap(
|
||||
queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]],
|
||||
slugs: string[]
|
||||
): Record<string, number[]> {
|
||||
const [error, rows] = queryResult;
|
||||
if (error) {
|
||||
console.error("Agent sparkline query failed:", error);
|
||||
return {};
|
||||
}
|
||||
return this.#buildSparklineFromRows(rows, slugs);
|
||||
}
|
||||
|
||||
#buildSparklineFromRows(
|
||||
rows: { task_identifier: string; bucket: string; val: number }[],
|
||||
slugs: string[]
|
||||
): Record<string, number[]> {
|
||||
const now = new Date();
|
||||
const startHour = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
now.getUTCHours() - 23,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const bucketKeys: string[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const h = new Date(startHour.getTime() + i * 3600_000);
|
||||
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val);
|
||||
}
|
||||
|
||||
const result: Record<string, number[]> = {};
|
||||
for (const slug of slugs) {
|
||||
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
|
||||
|
||||
function setupAgentListPresenter() {
|
||||
return new AgentListPresenter(clickhouseClient, $replica);
|
||||
}
|
||||
@@ -304,6 +304,7 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
metadata,
|
||||
taskKind: run.taskKind,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion)
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ClickHouse } from "@internal/clickhouse";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { RunAnnotations } from "@trigger.dev/core/v3/schemas";
|
||||
import {
|
||||
type PrismaClient,
|
||||
type PrismaClientOrTransaction,
|
||||
@@ -34,6 +35,7 @@ export type RunListOptions = {
|
||||
queues?: string[];
|
||||
machines?: MachinePresetName[];
|
||||
errorId?: string;
|
||||
sources?: string[];
|
||||
//pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
@@ -72,6 +74,7 @@ export class NextRunListPresenter {
|
||||
queues,
|
||||
machines,
|
||||
errorId,
|
||||
sources,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -89,6 +92,7 @@ export class NextRunListPresenter {
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
|
||||
const hasFilters =
|
||||
(sources !== undefined && sources.length > 0) ||
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
@@ -186,6 +190,7 @@ export class NextRunListPresenter {
|
||||
queues,
|
||||
machines,
|
||||
errorId,
|
||||
taskKinds: sources,
|
||||
page: {
|
||||
size: pageSize,
|
||||
cursor,
|
||||
@@ -250,6 +255,7 @@ export class NextRunListPresenter {
|
||||
name: run.queue.replace("task/", ""),
|
||||
type: run.queue.startsWith("task/") ? "task" : "custom",
|
||||
},
|
||||
taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD",
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { RuntimeEnvironmentType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
export type PlaygroundAgent = {
|
||||
slug: string;
|
||||
filePath: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
config: unknown;
|
||||
payloadSchema: unknown;
|
||||
};
|
||||
|
||||
export type PlaygroundConversation = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
title: string;
|
||||
agentSlug: string;
|
||||
runFriendlyId: string | null;
|
||||
runStatus: TaskRunStatus | null;
|
||||
clientData: unknown;
|
||||
messages: unknown;
|
||||
lastEventId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class PlaygroundPresenter {
|
||||
async listAgents({
|
||||
environmentId,
|
||||
environmentType,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
}): Promise<PlaygroundAgent[]> {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{ id: environmentId, type: environmentType },
|
||||
$replica
|
||||
);
|
||||
|
||||
if (!currentWorker) return [];
|
||||
|
||||
return $replica.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
payloadSchema: true,
|
||||
},
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async getAgent({
|
||||
environmentId,
|
||||
environmentType,
|
||||
agentSlug,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
agentSlug: string;
|
||||
}): Promise<PlaygroundAgent | null> {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{ id: environmentId, type: environmentType },
|
||||
$replica
|
||||
);
|
||||
|
||||
if (!currentWorker) return null;
|
||||
|
||||
return $replica.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
slug: agentSlug,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
payloadSchema: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentConversations({
|
||||
environmentId,
|
||||
agentSlug,
|
||||
userId,
|
||||
limit = 10,
|
||||
}: {
|
||||
environmentId: string;
|
||||
agentSlug: string;
|
||||
userId: string;
|
||||
limit?: number;
|
||||
}): Promise<PlaygroundConversation[]> {
|
||||
const conversations = await $replica.playgroundConversation.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
agentSlug,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
chatId: true,
|
||||
title: true,
|
||||
agentSlug: true,
|
||||
clientData: true,
|
||||
messages: true,
|
||||
lastEventId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
run: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return conversations.map((c) => ({
|
||||
id: c.id,
|
||||
chatId: c.chatId,
|
||||
title: c.title,
|
||||
agentSlug: c.agentSlug,
|
||||
runFriendlyId: c.run?.friendlyId ?? null,
|
||||
runStatus: c.run?.status ?? null,
|
||||
clientData: c.clientData,
|
||||
messages: c.messages,
|
||||
lastEventId: c.lastEventId,
|
||||
isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const playgroundPresenter = new PlaygroundPresenter();
|
||||
@@ -61,6 +61,7 @@ export class TaskListPresenter {
|
||||
const tasks = await this._replica.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: { not: "AGENT" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -19,15 +19,13 @@ export class TestPresenter extends BasePresenter {
|
||||
const tasks = await this.#getTasks(environmentId, isDev);
|
||||
|
||||
return {
|
||||
tasks: tasks.map((task) => {
|
||||
return {
|
||||
id: task.id,
|
||||
taskIdentifier: task.slug,
|
||||
filePath: task.filePath,
|
||||
friendlyId: task.friendlyId,
|
||||
triggerSource: task.triggerSource,
|
||||
};
|
||||
}),
|
||||
tasks: tasks.map((task) => ({
|
||||
id: task.id,
|
||||
taskIdentifier: task.slug,
|
||||
filePath: task.filePath,
|
||||
friendlyId: task.friendlyId,
|
||||
triggerSource: task.triggerSource,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,10 +52,13 @@ export class TestPresenter extends BasePresenter {
|
||||
SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource"
|
||||
FROM latest_workers
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
|
||||
WHERE bwt."triggerSource" != 'AGENT'
|
||||
ORDER BY slug ASC;`;
|
||||
} else {
|
||||
const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId });
|
||||
return currentDeployment?.worker?.tasks ?? [];
|
||||
return (currentDeployment?.worker?.tasks ?? []).filter(
|
||||
(t) => t.triggerSource !== "AGENT"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
import { BeakerIcon, CpuChipIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/node";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { TaskFileName } from "~/components/runs/v3/TaskPath";
|
||||
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type AgentListItem,
|
||||
type AgentActiveState,
|
||||
agentListPresenter,
|
||||
} from "~/presenters/v3/AgentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3RunsPath, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Agents | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const result = await agentListPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return typeddefer(result);
|
||||
};
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const { filterText, setFilterText, filteredItems } = useFuzzyFilter({
|
||||
items: agents,
|
||||
keys: ["slug", "filePath"],
|
||||
});
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Agents" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<CpuChipIcon className="size-12 text-indigo-500" />
|
||||
<Header2>No agents deployed</Header2>
|
||||
<Paragraph variant="small" className="max-w-md text-center">
|
||||
Create a chat agent using <code>chat.agent()</code> from{" "}
|
||||
<code>@trigger.dev/sdk/ai</code> and deploy it to see it here.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Agents" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full grid-rows-1">
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<Input
|
||||
placeholder="Search agents"
|
||||
variant="tertiary"
|
||||
icon={MagnifyingGlassIcon}
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Table containerClassName="max-h-full pb-[2.5rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>File</TableHeaderCell>
|
||||
<TableHeaderCell>Active</TableHeaderCell>
|
||||
<TableHeaderCell>Conversations (24h)</TableHeaderCell>
|
||||
<TableHeaderCell>Cost (24h)</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens (24h)</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((agent) => {
|
||||
const path = v3RunsPath(organization, project, environment, {
|
||||
tasks: [agent.slug],
|
||||
});
|
||||
const agentType =
|
||||
(agent.config as { type?: string } | null)?.type ?? "unknown";
|
||||
|
||||
return (
|
||||
<TableRow key={agent.slug} className="group">
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<CpuChipIcon className="size-[1.125rem] min-w-[1.125rem] text-indigo-500" />
|
||||
}
|
||||
content="Agent"
|
||||
/>
|
||||
<span>{agent.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<Badge variant="extra-small">{formatAgentType(agentType)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TaskFileName fileName={agent.filePath} variant="extra-extra-small" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<Suspense fallback={<Spinner color="muted" />}>
|
||||
<TypedAwait resolve={activeStates} errorElement={<>–</>}>
|
||||
{(data) => {
|
||||
const state = data[agent.slug];
|
||||
if (!state || (state.running === 0 && state.suspended === 0)) {
|
||||
return (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs">
|
||||
{state.running > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span>{state.running}</span>
|
||||
</span>
|
||||
)}
|
||||
{state.running > 0 && state.suspended > 0 && (
|
||||
<span className="text-text-dimmed">·</span>
|
||||
)}
|
||||
{state.suspended > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-1.5 rounded-full bg-blue-500" />
|
||||
<span>{state.suspended}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={conversationSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatCount}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={costSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatCost}
|
||||
color="text-amber-400"
|
||||
barColor="#F59E0B"
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={tokenSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatTokens}
|
||||
color="text-purple-400"
|
||||
barColor="#A855F7"
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={RunsIcon}
|
||||
to={path}
|
||||
title="View runs"
|
||||
leadingIconClassName="text-runs"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={BeakerIcon}
|
||||
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
|
||||
title="Playground"
|
||||
leadingIconClassName="text-indigo-400"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
|
||||
>
|
||||
Playground
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No agents match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgentType(type: string): string {
|
||||
switch (type) {
|
||||
case "ai-sdk-chat":
|
||||
return "AI SDK Chat";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCount(total: number): string {
|
||||
if (total === 0) return "0";
|
||||
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
function formatCost(total: number): string {
|
||||
if (total === 0) return "$0";
|
||||
if (total < 0.01) return `$${total.toFixed(4)}`;
|
||||
if (total < 1) return `$${total.toFixed(2)}`;
|
||||
return `$${total.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatTokens(total: number): string {
|
||||
if (total === 0) return "0";
|
||||
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`;
|
||||
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
function SparklinePlaceholder() {
|
||||
return <div className="h-6 w-24" />;
|
||||
}
|
||||
|
||||
function SparklineWithTotal({
|
||||
data,
|
||||
formatTotal,
|
||||
color = "text-text-bright",
|
||||
barColor = "#3B82F6",
|
||||
}: {
|
||||
data?: number[];
|
||||
formatTotal: (total: number) => string;
|
||||
color?: string;
|
||||
barColor?: string;
|
||||
}) {
|
||||
if (!data || data.every((v) => v === 0)) {
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, v) => sum + v, 0);
|
||||
const max = Math.max(...data);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 items-end gap-px">
|
||||
{data.map((value, i) => {
|
||||
const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-t-[1px]"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
backgroundColor: value > 0 ? barColor : "transparent",
|
||||
opacity: value > 0 ? 0.8 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn("text-xs tabular-nums", color)}>{formatTotal(total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1190
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
import { CpuChipIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type MetaFunction } from "@remix-run/node";
|
||||
import { Outlet, useNavigate, useParams, useLoaderData } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type PlaygroundAgent,
|
||||
playgroundPresenter,
|
||||
} from "~/presenters/v3/PlaygroundPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Playground | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const agents = await playgroundPresenter.listAgents({
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return json({ agents });
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const { agents } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const selectedAgent = params.agentParam ?? "";
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Playground" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<CpuChipIcon className="size-12 text-indigo-500" />
|
||||
<Header2>No agents deployed</Header2>
|
||||
<Paragraph variant="small" className="max-w-md text-center">
|
||||
Create a chat agent using <code>chat.agent()</code> from{" "}
|
||||
<code>@trigger.dev/sdk/ai</code> and deploy it to see it here.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Playground" />
|
||||
<PageAccessories>
|
||||
<Select
|
||||
value={selectedAgent}
|
||||
setValue={(slug) => {
|
||||
if (slug && typeof slug === "string") {
|
||||
navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
|
||||
}
|
||||
}}
|
||||
icon={<CpuChipIcon className="size-4 text-indigo-500" />}
|
||||
text={(val) => val || undefined}
|
||||
placeholder="Select an agent..."
|
||||
variant="tertiary/small"
|
||||
items={agents}
|
||||
filter={(item, search) =>
|
||||
item.slug.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((agent, index) => (
|
||||
<SelectItem key={agent.slug} value={agent.slug}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CpuChipIcon className="size-3.5 text-indigo-500" />
|
||||
<span>{agent.slug}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{selectedAgent ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<CpuChipIcon className="size-10 text-indigo-500/50" />
|
||||
<Header2 className="text-text-dimmed">Select an agent</Header2>
|
||||
<Paragraph variant="small" className="max-w-md text-center text-text-dimmed">
|
||||
Choose an agent from the dropdown to start a conversation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+11
-5
@@ -31,11 +31,17 @@ export function AIPayloadTabContent({
|
||||
payloadSchema,
|
||||
taskIdentifier,
|
||||
getCurrentPayload,
|
||||
generateButtonLabel = "Generate payload",
|
||||
placeholder,
|
||||
examplePromptsOverride,
|
||||
}: {
|
||||
onPayloadGenerated: (payload: string) => void;
|
||||
payloadSchema?: unknown;
|
||||
taskIdentifier: string;
|
||||
getCurrentPayload?: () => string;
|
||||
generateButtonLabel?: string;
|
||||
placeholder?: string;
|
||||
examplePromptsOverride?: string[];
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -191,7 +197,7 @@ export function AIPayloadTabContent({
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const examplePrompts = payloadSchema
|
||||
const examplePrompts = examplePromptsOverride ?? (payloadSchema
|
||||
? [
|
||||
"Generate a valid payload",
|
||||
"Generate a payload with edge cases",
|
||||
@@ -201,7 +207,7 @@ export function AIPayloadTabContent({
|
||||
"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">
|
||||
@@ -215,9 +221,9 @@ export function AIPayloadTabContent({
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
payloadSchema
|
||||
placeholder ?? (payloadSchema
|
||||
? "e.g. generate a payload for a new user signup"
|
||||
: "e.g. generate a JSON payload with name, email, and age fields"
|
||||
: "e.g. generate a JSON payload with name, email, and age fields")
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
@@ -251,7 +257,7 @@ export function AIPayloadTabContent({
|
||||
className={cn(!prompt.trim() && "opacity-50")}
|
||||
onClick={() => handleSubmit()}
|
||||
>
|
||||
Generate payload
|
||||
{generateButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+21
-5
@@ -9,18 +9,34 @@ import { docsPath } from "~/utils/pathBuilder";
|
||||
export function SchemaTabContent({
|
||||
schema,
|
||||
inferredSchema,
|
||||
title = "Payload schema",
|
||||
description,
|
||||
showDocsLink = true,
|
||||
}: {
|
||||
schema?: unknown;
|
||||
inferredSchema?: unknown;
|
||||
title?: string;
|
||||
description?: string;
|
||||
showDocsLink?: boolean;
|
||||
}) {
|
||||
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>
|
||||
<Header3 className="text-text-bright">{title}</Header3>
|
||||
{showDocsLink ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description ?? (
|
||||
<>
|
||||
JSON Schema defined by this task via{" "}
|
||||
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
) : description ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
code={JSON.stringify(schema, null, 2)}
|
||||
language="json"
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
generateJWT as internal_generateJWT,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.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 { TriggerTaskService } from "~/v3/services/triggerTask.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
|
||||
const PlaygroundAction = z.object({
|
||||
intent: z.enum(["create", "trigger", "renew", "save", "delete"]),
|
||||
agentSlug: z.string(),
|
||||
// For create
|
||||
conversationId: z.string().optional(),
|
||||
// For trigger
|
||||
chatId: z.string().optional(),
|
||||
payload: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
machine: z.string().optional(),
|
||||
// For renew
|
||||
runId: z.string().optional(),
|
||||
// For save
|
||||
messages: z.string().optional(),
|
||||
lastEventId: z.string().optional(),
|
||||
// For delete
|
||||
deleteConversationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { intent } = parsed.data;
|
||||
|
||||
switch (intent) {
|
||||
case "create": {
|
||||
const { agentSlug } = parsed.data;
|
||||
const chatId = crypto.randomUUID();
|
||||
|
||||
const conversation = await prisma.playgroundConversation.create({
|
||||
data: {
|
||||
chatId,
|
||||
agentSlug,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return json({
|
||||
conversationId: conversation.id,
|
||||
chatId,
|
||||
});
|
||||
}
|
||||
|
||||
case "trigger": {
|
||||
const { agentSlug, chatId, payload: payloadStr, clientData, tags: tagsStr, machine } = parsed.data;
|
||||
|
||||
if (!payloadStr || !chatId) {
|
||||
return json({ error: "payload and chatId are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadStr) as Record<string, any>;
|
||||
|
||||
const triggerService = new TriggerTaskService();
|
||||
const result = await triggerService.call(
|
||||
agentSlug,
|
||||
environment,
|
||||
{
|
||||
payload,
|
||||
options: {
|
||||
payloadType: "application/json",
|
||||
test: true,
|
||||
tags: [
|
||||
`chat:${chatId}`,
|
||||
"playground:true",
|
||||
...(tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : []),
|
||||
].slice(0, 5),
|
||||
machine: machine as any,
|
||||
},
|
||||
},
|
||||
{ triggerSource: "dashboard", triggerAction: "test", realtimeStreamsVersion: "v2" }
|
||||
);
|
||||
|
||||
if (!result?.run) {
|
||||
return json({ error: "Failed to trigger agent" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Create or update the playground conversation
|
||||
let parsedClientData: unknown;
|
||||
try {
|
||||
parsedClientData = clientData ? JSON.parse(clientData) : undefined;
|
||||
} catch {
|
||||
// Client data JSON was invalid — proceed without it
|
||||
}
|
||||
|
||||
// Extract first message text for title
|
||||
const firstMessage = payload?.messages?.[0];
|
||||
const firstText =
|
||||
firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
|
||||
const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
|
||||
|
||||
const conversation = await prisma.playgroundConversation.upsert({
|
||||
where: {
|
||||
chatId_runtimeEnvironmentId: {
|
||||
chatId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
chatId,
|
||||
title,
|
||||
agentSlug,
|
||||
runId: result.run.id,
|
||||
clientData: parsedClientData as any,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
update: {
|
||||
runId: result.run.id,
|
||||
clientData: parsedClientData as any,
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await mintRunToken(environment, result.run.friendlyId);
|
||||
|
||||
return json({
|
||||
runId: result.run.friendlyId,
|
||||
publicAccessToken: jwt,
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
}
|
||||
|
||||
case "renew": {
|
||||
const { runId } = parsed.data;
|
||||
if (!runId) {
|
||||
return json({ error: "runId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const jwt = await mintRunToken(environment, runId);
|
||||
return json({ publicAccessToken: jwt });
|
||||
}
|
||||
|
||||
case "save": {
|
||||
const { chatId, messages: messagesStr, lastEventId } = parsed.data;
|
||||
if (!chatId) {
|
||||
return json({ error: "chatId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
|
||||
|
||||
await prisma.playgroundConversation.updateMany({
|
||||
where: {
|
||||
chatId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
data: {
|
||||
...(messagesData ? { messages: messagesData as any } : {}),
|
||||
...(lastEventId ? { lastEventId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const { deleteConversationId } = parsed.data;
|
||||
if (!deleteConversationId) {
|
||||
return json({ error: "deleteConversationId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.playgroundConversation.deleteMany({
|
||||
where: {
|
||||
id: deleteConversationId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function mintRunToken(
|
||||
environment: Parameters<typeof extractJwtSigningSecretKey>[0],
|
||||
runFriendlyId: string
|
||||
): Promise<string> {
|
||||
return internal_generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [
|
||||
`read:runs:${runFriendlyId}`,
|
||||
`write:inputStreams:${runFriendlyId}`,
|
||||
],
|
||||
},
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription — authenticated via session cookie
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { runId, streamId } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
realtimeStreamsVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
|
||||
const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
|
||||
|
||||
if (timeoutInSeconds && (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
|
||||
|
||||
return realtimeStream.streamResponse(request, run.friendlyId, streamId, request.signal, {
|
||||
lastEventId,
|
||||
timeoutInSeconds,
|
||||
});
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import {
|
||||
getInputStreamWaitpoint,
|
||||
deleteInputStreamWaitpoint,
|
||||
} from "~/services/inputStreamWaitpointCache.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
data: z.unknown(),
|
||||
});
|
||||
|
||||
// POST: Send data to an input stream — authenticated via session cookie
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { runId, streamId } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ ok: false, error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ ok: false, error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
completedAt: true,
|
||||
realtimeStreamsVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ ok: false, error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (run.completedAt) {
|
||||
return json(
|
||||
{ ok: false, error: "Cannot send to input stream on a completed run" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = BodySchema.safeParse(await request.json());
|
||||
if (!body.success) {
|
||||
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
|
||||
|
||||
const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const record = JSON.stringify(body.data.data);
|
||||
|
||||
await realtimeStream.appendPart(
|
||||
record,
|
||||
recordId,
|
||||
run.friendlyId,
|
||||
`$trigger.input:${streamId}`
|
||||
);
|
||||
|
||||
// Complete any linked waitpoint
|
||||
const waitpointId = await getInputStreamWaitpoint(runId, streamId);
|
||||
if (waitpointId) {
|
||||
await engine.completeWaitpoint({
|
||||
id: waitpointId,
|
||||
output: {
|
||||
value: JSON.stringify(body.data.data),
|
||||
type: "application/json",
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
await deleteInputStreamWaitpoint(runId, streamId);
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
},
|
||||
},
|
||||
select: {
|
||||
spanId: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
slug: true,
|
||||
@@ -57,11 +58,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
// Preserve existing search params from the request, add span if not already set
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
if (!searchParams.has("span") && run.spanId) {
|
||||
searchParams.set("span", run.spanId);
|
||||
}
|
||||
|
||||
const path = v3RunPath(
|
||||
{ slug: run.project.organization.slug },
|
||||
{ slug: run.project.slug },
|
||||
{ slug: run.runtimeEnvironment.slug },
|
||||
{ friendlyId: runParam }
|
||||
{ friendlyId: runParam },
|
||||
searchParams
|
||||
);
|
||||
|
||||
return redirect(path);
|
||||
|
||||
@@ -79,6 +79,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
let queueName: string;
|
||||
let lockedQueueId: string | undefined;
|
||||
let taskTtl: string | null | undefined;
|
||||
let taskKind: string | undefined;
|
||||
|
||||
// Determine queue name based on lockToVersion and provided options
|
||||
if (lockedBackgroundWorker) {
|
||||
@@ -158,6 +159,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
// Use the task's default queue name
|
||||
queueName = lockedTask.queue.name;
|
||||
lockedQueueId = lockedTask.queue.id;
|
||||
taskKind = lockedTask.triggerSource;
|
||||
}
|
||||
} else {
|
||||
// Task is not locked to a specific version, use regular logic
|
||||
@@ -172,6 +174,7 @@ export class DefaultQueueManager implements QueueManager {
|
||||
const taskInfo = await this.getTaskQueueInfo(request);
|
||||
queueName = taskInfo.queueName;
|
||||
taskTtl = taskInfo.taskTtl;
|
||||
taskKind = taskInfo.taskKind;
|
||||
}
|
||||
|
||||
// Sanitize the final determined queue name once
|
||||
@@ -188,12 +191,13 @@ export class DefaultQueueManager implements QueueManager {
|
||||
queueName,
|
||||
lockedQueueId,
|
||||
taskTtl,
|
||||
taskKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async getTaskQueueInfo(
|
||||
request: TriggerTaskRequest
|
||||
): Promise<{ queueName: string; taskTtl?: string | null }> {
|
||||
): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> {
|
||||
const { taskId, environment, body } = request;
|
||||
const { queue } = body.options ?? {};
|
||||
|
||||
@@ -228,10 +232,10 @@ export class DefaultQueueManager implements QueueManager {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
slug: taskId,
|
||||
},
|
||||
select: { ttl: true },
|
||||
select: { ttl: true, triggerSource: true },
|
||||
});
|
||||
|
||||
return { queueName: overriddenQueueName, taskTtl: task?.ttl };
|
||||
return { queueName: overriddenQueueName, taskTtl: task?.ttl, taskKind: task?.triggerSource };
|
||||
}
|
||||
|
||||
const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
@@ -261,10 +265,10 @@ export class DefaultQueueManager implements QueueManager {
|
||||
queueConfig: task.queueConfig,
|
||||
});
|
||||
|
||||
return { queueName: defaultQueueName, taskTtl: task.ttl };
|
||||
return { queueName: defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
|
||||
}
|
||||
|
||||
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl };
|
||||
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
|
||||
}
|
||||
|
||||
async validateQueueLimits(
|
||||
|
||||
@@ -185,7 +185,7 @@ export class RunEngineTriggerTaskService {
|
||||
if (debounceDelayError || !debounceDelayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -193,11 +193,11 @@ export class RunEngineTriggerTaskService {
|
||||
// Get parent run if specified
|
||||
const parentRun = body.options?.parentRunId
|
||||
? await this.prisma.taskRun.findFirst({
|
||||
where: {
|
||||
id: RunId.fromFriendlyId(body.options.parentRunId),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
where: {
|
||||
id: RunId.fromFriendlyId(body.options.parentRunId),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Validate parent run
|
||||
@@ -231,21 +231,21 @@ export class RunEngineTriggerTaskService {
|
||||
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await this.prisma.backgroundWorker.findFirst({
|
||||
where: {
|
||||
projectId: environment.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: body.options?.lockToVersion,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
})
|
||||
where: {
|
||||
projectId: environment.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: body.options?.lockToVersion,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const { queueName, lockedQueueId, taskTtl } =
|
||||
const { queueName, lockedQueueId, taskTtl, taskKind } =
|
||||
await this.queueConcern.resolveQueueProperties(
|
||||
triggerRequest,
|
||||
lockedToBackgroundWorker ?? undefined
|
||||
@@ -281,10 +281,10 @@ export class RunEngineTriggerTaskService {
|
||||
|
||||
const metadataPacket = body.options?.metadata
|
||||
? handleMetadataPacket(
|
||||
body.options?.metadata,
|
||||
body.options?.metadataType ?? "application/json",
|
||||
this.metadataMaximumSize
|
||||
)
|
||||
body.options?.metadata,
|
||||
body.options?.metadataType ?? "application/json",
|
||||
this.metadataMaximumSize
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const tags = (
|
||||
@@ -313,6 +313,7 @@ export class RunEngineTriggerTaskService {
|
||||
triggerAction,
|
||||
rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource,
|
||||
rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined,
|
||||
taskKind: taskKind ?? "STANDARD",
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -369,9 +370,9 @@ export class RunEngineTriggerTaskService {
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
@@ -402,26 +403,26 @@ export class RunEngineTriggerTaskService {
|
||||
onDebounced:
|
||||
body.options?.debounce && body.options?.resumeParentOnCompletion
|
||||
? async ({ existingRun, waitpoint, debounceKey }) => {
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
|
||||
: spanEvent.spanId;
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.prisma
|
||||
|
||||
@@ -37,18 +37,19 @@ export type TriggerTaskResult = {
|
||||
|
||||
export type QueueValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
maximumSize: number;
|
||||
queueSize: number;
|
||||
};
|
||||
ok: false;
|
||||
maximumSize: number;
|
||||
queueSize: number;
|
||||
};
|
||||
|
||||
export type QueueProperties = {
|
||||
queueName: string;
|
||||
lockedQueueId?: string;
|
||||
taskTtl?: string | null;
|
||||
taskKind?: string;
|
||||
};
|
||||
|
||||
export type LockedBackgroundWorker = Pick<
|
||||
@@ -98,22 +99,22 @@ export interface ParentRunValidationParams {
|
||||
|
||||
export type ValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type EntitlementValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
plan?: ReportUsagePlan;
|
||||
}
|
||||
ok: true;
|
||||
plan?: ReportUsagePlan;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export interface TriggerTaskValidator {
|
||||
validateTags(params: TagValidationParams): ValidationResult;
|
||||
|
||||
@@ -921,6 +921,7 @@ export class RunsReplicationService {
|
||||
run.maxDurationInSeconds ?? null, // max_duration_in_seconds
|
||||
annotations?.triggerSource ?? "", // trigger_source
|
||||
annotations?.rootTriggerSource ?? "", // root_trigger_source
|
||||
annotations?.taskKind ?? "", // task_kind
|
||||
run.isWarmStart ?? null, // is_warm_start
|
||||
];
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
|
||||
metadataType: true,
|
||||
machinePreset: true,
|
||||
queue: true,
|
||||
annotations: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -334,4 +335,22 @@ function applyRunFiltersToQueryBuilder<T>(
|
||||
errorFingerprint: ErrorId.toId(options.errorId),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.taskKinds && options.taskKinds.length > 0) {
|
||||
const includesStandard = options.taskKinds.includes("STANDARD");
|
||||
// Include empty string when filtering for STANDARD (default value for pre-existing runs)
|
||||
const effectiveKinds = includesStandard
|
||||
? [...options.taskKinds, ""]
|
||||
: options.taskKinds;
|
||||
|
||||
if (effectiveKinds.length === 1) {
|
||||
queryBuilder.where("task_kind = {taskKind: String}", {
|
||||
taskKind: effectiveKinds[0]!,
|
||||
});
|
||||
} else {
|
||||
queryBuilder.where("task_kind IN {taskKinds: Array(String)}", {
|
||||
taskKinds: effectiveKinds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const RunListInputOptionsSchema = z.object({
|
||||
queues: z.array(z.string()).optional(),
|
||||
machines: MachinePresetName.array().optional(),
|
||||
errorId: z.string().optional(),
|
||||
taskKinds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type RunListInputOptions = z.infer<typeof RunListInputOptionsSchema>;
|
||||
@@ -53,6 +54,7 @@ export type RunListInputFilters = Omit<
|
||||
export type ParsedRunFilters = RunListInputFilters & {
|
||||
cursor?: string;
|
||||
direction?: "forward" | "backward";
|
||||
sources?: string[];
|
||||
};
|
||||
|
||||
export type FilterRunsOptions = Omit<RunListInputOptions, "period"> & {
|
||||
@@ -102,6 +104,7 @@ export type ListedRun = Prisma.TaskRunGetPayload<{
|
||||
metadataType: true;
|
||||
machinePreset: true;
|
||||
queue: true;
|
||||
annotations: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
||||
@@ -314,6 +314,31 @@ export function v3TestTaskPath(
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function v3PlaygroundPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/playground`;
|
||||
}
|
||||
|
||||
export function v3PlaygroundAgentPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath,
|
||||
agentSlug: string
|
||||
) {
|
||||
return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`;
|
||||
}
|
||||
|
||||
export function v3AgentsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
environment: EnvironmentForPath
|
||||
) {
|
||||
return `${v3EnvironmentPath(organization, project, environment)}/agents`;
|
||||
}
|
||||
|
||||
export function v3RunsPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -288,6 +288,19 @@ async function createWorkerTask(
|
||||
);
|
||||
}
|
||||
|
||||
// @crumbs
|
||||
console.log(`[crumbs:webapp] createWorkerTask task=${task.id} triggerSource=${task.triggerSource} agentConfig=${JSON.stringify(task.agentConfig)} taskKeys=${Object.keys(task).join(",")}`); // @crumbs
|
||||
|
||||
const resolvedTriggerSource =
|
||||
task.triggerSource === "schedule"
|
||||
? ("SCHEDULED" as const)
|
||||
: task.triggerSource === "agent"
|
||||
? ("AGENT" as const)
|
||||
: ("STANDARD" as const);
|
||||
|
||||
// @crumbs
|
||||
console.log(`[crumbs:webapp] createWorkerTask resolved triggerSource=${resolvedTriggerSource} for task=${task.id}`); // @crumbs
|
||||
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("task"),
|
||||
@@ -301,7 +314,8 @@ async function createWorkerTask(
|
||||
retryConfig: task.retry,
|
||||
queueConfig: task.queue,
|
||||
machineConfig: task.machine,
|
||||
triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD",
|
||||
triggerSource: resolvedTriggerSource,
|
||||
config: task.agentConfig ? (task.agentConfig as any) : undefined,
|
||||
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
|
||||
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
|
||||
ttl:
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.23",
|
||||
"@ai-sdk/react": "^3.0.0",
|
||||
"@ariakit/react": "^0.4.6",
|
||||
"@ariakit/react-core": "^0.4.6",
|
||||
"@aws-sdk/client-ecr": "^3.931.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
ADD COLUMN task_kind LowCardinality(String) DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.task_runs_v2
|
||||
DROP COLUMN task_kind;
|
||||
@@ -51,6 +51,7 @@ export const TaskRunV2 = z.object({
|
||||
max_duration_in_seconds: z.number().int().nullish(),
|
||||
trigger_source: z.string().default(""),
|
||||
root_trigger_source: z.string().default(""),
|
||||
task_kind: z.string().default(""),
|
||||
is_warm_start: z.boolean().nullish(),
|
||||
_version: z.string(),
|
||||
_is_deleted: z.number().int().default(0),
|
||||
@@ -110,6 +111,7 @@ export const TASK_RUN_COLUMNS = [
|
||||
"max_duration_in_seconds",
|
||||
"trigger_source",
|
||||
"root_trigger_source",
|
||||
"task_kind",
|
||||
"is_warm_start",
|
||||
] as const;
|
||||
|
||||
@@ -176,6 +178,7 @@ export type TaskRunFieldTypes = {
|
||||
max_duration_in_seconds: number | null;
|
||||
trigger_source: string;
|
||||
root_trigger_source: string;
|
||||
task_kind: string;
|
||||
is_warm_start: boolean | null;
|
||||
};
|
||||
|
||||
@@ -313,6 +316,7 @@ export type TaskRunInsertArray = [
|
||||
max_duration_in_seconds: number | null,
|
||||
trigger_source: string,
|
||||
root_trigger_source: string,
|
||||
task_kind: string,
|
||||
is_warm_start: boolean | null,
|
||||
];
|
||||
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'AGENT';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN "config" JSONB;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."PlaygroundConversation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL DEFAULT 'New conversation',
|
||||
"agentSlug" TEXT NOT NULL,
|
||||
"runId" TEXT,
|
||||
"clientData" JSONB,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"runtimeEnvironmentId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlaygroundConversation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlaygroundConversation_runtimeEnvironmentId_agentSlug_updat_idx" ON "public"."PlaygroundConversation"("runtimeEnvironmentId", "agentSlug", "updatedAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlaygroundConversation_userId_runtimeEnvironmentId_idx" ON "public"."PlaygroundConversation"("userId", "runtimeEnvironmentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PlaygroundConversation_chatId_runtimeEnvironmentId_key" ON "public"."PlaygroundConversation"("chatId", "runtimeEnvironmentId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runId_fkey" FOREIGN KEY ("runId") REFERENCES "public"."TaskRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."PlaygroundConversation" ADD COLUMN "lastEventId" TEXT,
|
||||
ADD COLUMN "messages" JSONB;
|
||||
@@ -376,7 +376,8 @@ model RuntimeEnvironment {
|
||||
waitpointTags WaitpointTag[]
|
||||
BulkActionGroup BulkActionGroup[]
|
||||
customerQueries CustomerQuery[]
|
||||
prompts Prompt[]
|
||||
prompts Prompt[]
|
||||
playgroundConversations PlaygroundConversation[]
|
||||
errorGroupStates ErrorGroupState[]
|
||||
taskIdentifiers TaskIdentifier[]
|
||||
revokedApiKeys RevokedApiKey[]
|
||||
@@ -460,6 +461,7 @@ model Project {
|
||||
connectedGithubRepository ConnectedGithubRepository?
|
||||
organizationProjectIntegration OrganizationProjectIntegration[]
|
||||
customerQueries CustomerQuery[]
|
||||
playgroundConversations PlaygroundConversation[]
|
||||
|
||||
buildSettings Json?
|
||||
onboardingData Json?
|
||||
@@ -696,6 +698,10 @@ model BackgroundWorkerTask {
|
||||
|
||||
triggerSource TaskTriggerSource @default(STANDARD)
|
||||
|
||||
/// Extra task configuration JSON. Shape depends on triggerSource.
|
||||
/// AGENT: { type: "ai-sdk-chat" }
|
||||
config Json?
|
||||
|
||||
payloadSchema Json?
|
||||
|
||||
@@unique([workerId, slug])
|
||||
@@ -708,6 +714,49 @@ model BackgroundWorkerTask {
|
||||
enum TaskTriggerSource {
|
||||
STANDARD
|
||||
SCHEDULED
|
||||
AGENT
|
||||
}
|
||||
|
||||
model PlaygroundConversation {
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// The chat session ID used by the transport
|
||||
chatId String
|
||||
|
||||
/// User-editable conversation title (auto-generated from first message)
|
||||
title String @default("New conversation")
|
||||
|
||||
/// Which agent this conversation is with
|
||||
agentSlug String
|
||||
|
||||
/// The current active run backing this conversation (null if no run yet)
|
||||
runId String?
|
||||
run TaskRun? @relation(fields: [runId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
|
||||
/// The client data JSON used for this conversation
|
||||
clientData Json?
|
||||
|
||||
/// Accumulated UIMessages from completed turns (for resume without stream replay)
|
||||
messages Json?
|
||||
|
||||
/// Last SSE event ID — resume from this position to avoid replaying old turns
|
||||
lastEventId String?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
projectId String
|
||||
|
||||
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
runtimeEnvironmentId String
|
||||
|
||||
/// The user who started this conversation
|
||||
userId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([chatId, runtimeEnvironmentId])
|
||||
@@index([runtimeEnvironmentId, agentSlug, updatedAt(sort: Desc)])
|
||||
@@index([userId, runtimeEnvironmentId])
|
||||
}
|
||||
|
||||
/// Durable, typed, bidirectional I/O primitive. Owns two S2 streams (.out / .in).
|
||||
@@ -1011,6 +1060,8 @@ model TaskRun {
|
||||
/// (OSS, or pre-backfill); reads fall back to the global basin.
|
||||
streamBasinName String?
|
||||
|
||||
playgroundConversations PlaygroundConversation[]
|
||||
|
||||
@@unique([oneTimeUseToken])
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
// Finding child runs
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { trail } from "agentcrumbs"; // @crumbs
|
||||
const _cliCrumb = trail("cli"); // @crumbs
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -345,6 +347,23 @@ class DevSupervisor implements WorkerRuntime {
|
||||
|
||||
const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks);
|
||||
|
||||
// #region @crumbs
|
||||
const _agentTasksSupervisor = (backgroundWorker.manifest.tasks as any[]).filter(
|
||||
(t: any) => t.triggerSource || t.agentConfig
|
||||
);
|
||||
_cliCrumb("devSupervisor sending worker metadata to API", {
|
||||
totalTasks: backgroundWorker.manifest.tasks.length,
|
||||
agentTasks: _agentTasksSupervisor.map((t: any) => ({
|
||||
id: t.id,
|
||||
triggerSource: t.triggerSource,
|
||||
agentConfig: t.agentConfig,
|
||||
})),
|
||||
manifestTaskKeys: backgroundWorker.manifest.tasks[0]
|
||||
? Object.keys(backgroundWorker.manifest.tasks[0])
|
||||
: [],
|
||||
});
|
||||
// #endregion @crumbs
|
||||
|
||||
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
|
||||
localOnly: true,
|
||||
metadata: {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { trail } from "agentcrumbs"; // @crumbs
|
||||
const _cliCrumb = trail("cli"); // @crumbs
|
||||
import {
|
||||
BuildManifest,
|
||||
type HandleErrorFunction,
|
||||
@@ -119,6 +121,18 @@ const { buildManifest, importErrors, config, timings } = await bootstrap();
|
||||
|
||||
let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests());
|
||||
|
||||
// #region @crumbs
|
||||
const _agentTasks = tasks.filter((t: any) => t.triggerSource || t.agentConfig);
|
||||
_cliCrumb("dev-index-worker tasks after listTaskManifests", {
|
||||
totalTasks: tasks.length,
|
||||
agentTasks: _agentTasks.map((t: any) => ({
|
||||
id: t.id,
|
||||
triggerSource: t.triggerSource,
|
||||
agentConfig: t.agentConfig,
|
||||
})),
|
||||
});
|
||||
// #endregion @crumbs
|
||||
|
||||
// If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings
|
||||
if (config.retries?.default) {
|
||||
tasks = tasks.map((task) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { trail } from "agentcrumbs"; // @crumbs
|
||||
const _coreCrumb = trail("core"); // @crumbs
|
||||
import {
|
||||
PromptManifest,
|
||||
PromptMetadata,
|
||||
@@ -73,6 +75,15 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
return;
|
||||
}
|
||||
|
||||
// #region @crumbs
|
||||
_coreCrumb("registerTaskMetadata", {
|
||||
taskId: task.id,
|
||||
triggerSource: metadata.triggerSource,
|
||||
agentConfig: metadata.agentConfig,
|
||||
metadataKeys: Object.keys(metadata),
|
||||
});
|
||||
// #endregion @crumbs
|
||||
|
||||
this._taskFileMetadata.set(task.id, {
|
||||
...this._currentFileContext,
|
||||
});
|
||||
@@ -86,25 +97,31 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
}
|
||||
|
||||
updateTaskMetadata(id: string, updates: Partial<TaskMetadataWithFunctions>): void {
|
||||
const { fns, schema, ...metadataUpdates } = updates;
|
||||
|
||||
const existingMetadata = this._taskMetadata.get(id);
|
||||
|
||||
if (existingMetadata) {
|
||||
if (existingMetadata && Object.keys(metadataUpdates).length > 0) {
|
||||
this._taskMetadata.set(id, {
|
||||
...existingMetadata,
|
||||
...updates,
|
||||
...metadataUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.fns) {
|
||||
if (fns) {
|
||||
const existingFunctions = this._taskFunctions.get(id);
|
||||
|
||||
if (existingFunctions) {
|
||||
this._taskFunctions.set(id, {
|
||||
...existingFunctions,
|
||||
...updates.fns,
|
||||
...fns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (schema) {
|
||||
this._taskSchemas.set(id, schema);
|
||||
}
|
||||
}
|
||||
|
||||
// Return all the tasks, without the functions
|
||||
@@ -123,6 +140,18 @@ export class StandardResourceCatalog implements ResourceCatalog {
|
||||
...fileMetadata,
|
||||
};
|
||||
|
||||
// #region @crumbs
|
||||
if (metadata.triggerSource || metadata.agentConfig) {
|
||||
_coreCrumb("listTaskManifests building manifest", {
|
||||
taskId: id,
|
||||
triggerSource: metadata.triggerSource,
|
||||
agentConfig: metadata.agentConfig,
|
||||
manifestTriggerSource: taskManifest.triggerSource,
|
||||
manifestAgentConfig: (taskManifest as any).agentConfig,
|
||||
});
|
||||
}
|
||||
// #endregion @crumbs
|
||||
|
||||
result.push(taskManifest);
|
||||
}
|
||||
|
||||
|
||||
@@ -1115,6 +1115,7 @@ const CommonRunFields = {
|
||||
baseCostInCents: z.number(),
|
||||
durationMs: z.number(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
taskKind: z.string().optional(),
|
||||
};
|
||||
|
||||
const RetrieveRunCommandFields = {
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from "zod";
|
||||
import { QueueManifest, RetryOptions, ScheduleMetadata } from "./schemas.js";
|
||||
import { MachineConfig } from "./common.js";
|
||||
|
||||
export const AgentConfig = z.object({
|
||||
type: z.string(), // "ai-sdk-chat" initially, extensible for future agent types
|
||||
});
|
||||
|
||||
export type AgentConfig = z.infer<typeof AgentConfig>;
|
||||
|
||||
export const TaskResource = z.object({
|
||||
id: z.string(),
|
||||
description: z.string().optional(),
|
||||
@@ -11,6 +17,7 @@ export const TaskResource = z.object({
|
||||
retry: RetryOptions.optional(),
|
||||
machine: MachineConfig.optional(),
|
||||
triggerSource: z.string().optional(),
|
||||
agentConfig: AgentConfig.optional(),
|
||||
schedule: ScheduleMetadata.optional(),
|
||||
maxDuration: z.number().optional(),
|
||||
ttl: z.string().or(z.number().nonnegative().int()).optional(),
|
||||
|
||||
@@ -15,11 +15,15 @@ export const TriggerAction = z.enum(["trigger", "replay", "test"]).or(anyString)
|
||||
|
||||
export type TriggerAction = z.infer<typeof TriggerAction>;
|
||||
|
||||
export const TaskKind = z.enum(["STANDARD", "SCHEDULED", "AGENT"]).or(anyString);
|
||||
export type TaskKind = z.infer<typeof TaskKind>;
|
||||
|
||||
export const RunAnnotations = z.object({
|
||||
triggerSource: TriggerSource,
|
||||
triggerAction: TriggerAction,
|
||||
rootTriggerSource: TriggerSource,
|
||||
rootScheduleId: z.string().optional(),
|
||||
taskKind: TaskKind.optional(),
|
||||
});
|
||||
|
||||
export type RunAnnotations = z.infer<typeof RunAnnotations>;
|
||||
|
||||
@@ -180,6 +180,10 @@ export const ScheduleMetadata = z.object({
|
||||
environments: z.array(EnvironmentType).optional(),
|
||||
});
|
||||
|
||||
const AgentConfig = z.object({
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
const taskMetadata = {
|
||||
id: z.string(),
|
||||
description: z.string().optional(),
|
||||
@@ -187,6 +191,7 @@ const taskMetadata = {
|
||||
retry: RetryOptions.optional(),
|
||||
machine: MachineConfig.optional(),
|
||||
triggerSource: z.string().optional(),
|
||||
agentConfig: AgentConfig.optional(),
|
||||
schedule: ScheduleMetadata.optional(),
|
||||
maxDuration: z.number().optional(),
|
||||
ttl: z.string().or(z.number().nonnegative().int()).optional(),
|
||||
|
||||
@@ -387,6 +387,12 @@ type CommonTaskOptions<
|
||||
* Should be a valid JSON Schema Draft 7 object.
|
||||
*/
|
||||
jsonSchema?: JSONSchema;
|
||||
|
||||
/** @internal Set by SDK internals (e.g. `chat.agent()`, `schedules.task()`). */
|
||||
triggerSource?: string;
|
||||
|
||||
/** @internal Agent configuration, only set when `triggerSource` is `"agent"`. */
|
||||
agentConfig?: { type: string };
|
||||
};
|
||||
|
||||
export type TaskOptions<
|
||||
|
||||
+1062
-1002
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
* Browser-safe module for AI SDK chat transport integration.
|
||||
* Use this on the frontend with the AI SDK's `useChat` hook.
|
||||
*
|
||||
* For backend helpers (`chatTask`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
|
||||
* For backend helpers (`chatAgent`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -104,7 +104,7 @@ export type TriggerChatTaskResult = {
|
||||
type TriggerChatTransportOptionsBase<TClientData = unknown> = {
|
||||
/**
|
||||
* The Trigger.dev task ID to trigger for chat completions.
|
||||
* This task should be defined using `chatTask()` from `@trigger.dev/sdk/ai`,
|
||||
* This task should be defined using `chatAgent()` from `@trigger.dev/sdk/ai`,
|
||||
* or a regular `task()` that uses `pipeChat()`.
|
||||
*/
|
||||
task: string;
|
||||
@@ -117,7 +117,7 @@ type TriggerChatTransportOptionsBase<TClientData = unknown> = {
|
||||
|
||||
/**
|
||||
* The stream key where the task pipes UIMessageChunk data.
|
||||
* When using `chatTask()` or `pipeChat()`, this is handled automatically.
|
||||
* When using `chatAgent()` or `pipeChat()`, this is handled automatically.
|
||||
* Only set this if you're using a custom stream key.
|
||||
*
|
||||
* @default "chat"
|
||||
@@ -664,6 +664,22 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
this.triggerTaskFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject or update a session for a chat. Useful for resuming conversations
|
||||
* from persisted state without recreating the transport.
|
||||
*/
|
||||
setSession(
|
||||
chatId: string,
|
||||
session: { runId: string; publicAccessToken: string; lastEventId?: string }
|
||||
): void {
|
||||
this.sessions.set(chatId, {
|
||||
runId: session.runId,
|
||||
publicAccessToken: session.publicAccessToken,
|
||||
lastEventId: session.lastEventId,
|
||||
});
|
||||
this.notifySessionChange(chatId, this.sessions.get(chatId)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly trigger a run for a chat before the first message is sent.
|
||||
* This allows initialization (DB setup, context loading) to happen
|
||||
@@ -676,15 +692,23 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
*
|
||||
* No-op if a session already exists for this chatId.
|
||||
*/
|
||||
async preload(chatId: string, options?: { idleTimeoutInSeconds?: number }): Promise<void> {
|
||||
async preload(
|
||||
chatId: string,
|
||||
options?: { idleTimeoutInSeconds?: number; metadata?: Record<string, unknown> }
|
||||
): Promise<void> {
|
||||
// Don't preload if session already exists
|
||||
if (this.sessions.get(chatId)?.runId) return;
|
||||
|
||||
const mergedMetadata =
|
||||
this.defaultMetadata || options?.metadata
|
||||
? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
|
||||
: undefined;
|
||||
|
||||
const payload = {
|
||||
messages: [] as never[],
|
||||
chatId,
|
||||
trigger: "preload" as const,
|
||||
metadata: this.defaultMetadata,
|
||||
metadata: mergedMetadata,
|
||||
...(options?.idleTimeoutInSeconds !== undefined
|
||||
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SpanKind } from "@opentelemetry/api";
|
||||
import { trail } from "agentcrumbs"; // @crumbs
|
||||
const _sdkCrumb = trail("sdk"); // @crumbs
|
||||
import { SerializableJson } from "@trigger.dev/core";
|
||||
import {
|
||||
accessoryAttributes,
|
||||
@@ -250,12 +252,25 @@ export function createTask<
|
||||
|
||||
registerTaskLifecycleHooks(params.id, params);
|
||||
|
||||
// #region @crumbs
|
||||
_sdkCrumb("createTask registerTaskMetadata", {
|
||||
taskId: params.id,
|
||||
triggerSource: params.triggerSource,
|
||||
agentConfig: params.agentConfig,
|
||||
hasTriggerSource: "triggerSource" in params,
|
||||
hasAgentConfig: "agentConfig" in params,
|
||||
paramKeys: Object.keys(params).filter((k: string) => k.includes("trigger") || k.includes("agent") || k.includes("config")),
|
||||
});
|
||||
// #endregion @crumbs
|
||||
|
||||
resourceCatalog.registerTaskMetadata({
|
||||
id: params.id,
|
||||
description: params.description,
|
||||
queue: params.queue,
|
||||
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
|
||||
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
|
||||
triggerSource: params.triggerSource,
|
||||
agentConfig: params.agentConfig,
|
||||
maxDuration: params.maxDuration,
|
||||
ttl: params.ttl,
|
||||
payloadSchema: params.jsonSchema,
|
||||
@@ -408,6 +423,8 @@ export function createSchemaTask<
|
||||
queue: params.queue,
|
||||
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
|
||||
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
|
||||
triggerSource: params.triggerSource,
|
||||
agentConfig: params.agentConfig,
|
||||
maxDuration: params.maxDuration,
|
||||
ttl: params.ttl,
|
||||
fns: {
|
||||
|
||||
Generated
+16
-3
@@ -242,6 +242,9 @@ importers:
|
||||
'@ai-sdk/openai':
|
||||
specifier: ^1.3.23
|
||||
version: 1.3.23(zod@3.25.76)
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.170(react@18.2.0)(zod@3.25.76)
|
||||
'@ariakit/react':
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -21402,6 +21405,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
ai: 6.0.168(zod@3.25.76)
|
||||
react: 18.2.0
|
||||
swr: 2.2.5(react@18.2.0)
|
||||
throttleit: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
@@ -24881,9 +24894,9 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
'@hono/node-server@1.12.2(hono@4.5.11)':
|
||||
'@hono/node-server@1.12.2(hono@4.12.15)':
|
||||
dependencies:
|
||||
hono: 4.5.11
|
||||
hono: 4.12.15
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.15)':
|
||||
dependencies:
|
||||
@@ -24899,7 +24912,7 @@ snapshots:
|
||||
|
||||
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.12.2(hono@4.5.11)
|
||||
'@hono/node-server': 1.12.2(hono@4.12.15)
|
||||
ws: 8.18.3(bufferutil@4.0.9)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ChatSidebar({
|
||||
onChange={(e) => onTaskModeChange(e.target.value)}
|
||||
className="flex-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="ai-chat">ai-chat (chat.task)</option>
|
||||
<option value="ai-chat">ai-chat (chat.agent)</option>
|
||||
<option value="ai-chat-raw">ai-chat-raw (raw task)</option>
|
||||
<option value="ai-chat-session">ai-chat-session (session)</option>
|
||||
</select>
|
||||
|
||||
@@ -308,7 +308,7 @@ export const executeJs = tool({
|
||||
},
|
||||
});
|
||||
|
||||
/** Tool set passed to `streamText` for the main `chat.task` run (includes PostHog). */
|
||||
/** Tool set passed to `streamText` for the main `chat.agent` run (includes PostHog). */
|
||||
export const chatTools = {
|
||||
inspectEnvironment,
|
||||
webFetch,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* E2B sandboxes keyed by Trigger run id.
|
||||
*
|
||||
* - Warmed from `chat.task` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
|
||||
* - Disposed in task `onWait` when `wait.type === "token"` (input-stream suspend, same path as `wait.for` tokens).
|
||||
* - `onComplete` disposes any leftover sandbox if the run ends without hitting another token wait.
|
||||
* - Warmed from `chat.agent` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
|
||||
* - Disposed in `onChatSuspend` before the run suspends waiting for the next message.
|
||||
* - `onComplete` disposes any leftover sandbox if the run ends without hitting another suspend.
|
||||
*
|
||||
* No extra `chat.task` SDK hook is required for the suspend boundary — platform `onWait` is sufficient.
|
||||
* No extra SDK hook is required beyond `onChatSuspend` and `onComplete`.
|
||||
*/
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
|
||||
@@ -146,7 +146,7 @@ const userContext = chat.local<{
|
||||
// #endregion
|
||||
|
||||
// ============================================================================
|
||||
// chat.task — the main chat agent
|
||||
// chat.agent — the main chat agent
|
||||
// ============================================================================
|
||||
|
||||
export const aiChat = chat
|
||||
@@ -172,7 +172,7 @@ export const aiChat = chat
|
||||
.onChatResume(async ({ phase, ctx }) => {
|
||||
logger.debug("Chat resumed", { phase, runId: ctx.run.id });
|
||||
})
|
||||
.task({
|
||||
.agent({
|
||||
id: "ai-chat",
|
||||
idleTimeoutInSeconds: 60,
|
||||
chatAccessTokenTTL: "1m",
|
||||
@@ -442,7 +442,7 @@ export const aiChat = chat
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region run — just return streamText(), chat.task handles everything else
|
||||
// #region run — just return streamText(), chat.agent handles everything else
|
||||
run: async ({ messages, clientData, stopSignal }) => {
|
||||
userContext.messageCount++;
|
||||
if (clientData?.model) {
|
||||
|
||||
@@ -1,17 +1,76 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
|
||||
import { secureExec } from "@trigger.dev/build/extensions/secureExec";
|
||||
import { esbuildPlugin } from "@trigger.dev/build/extensions";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
project: process.env.TRIGGER_PROJECT_REF!,
|
||||
dirs: ["./src/trigger"],
|
||||
maxDuration: 3600,
|
||||
runtime: "node-22",
|
||||
build: {
|
||||
extensions: [
|
||||
prismaExtension({
|
||||
mode: "modern",
|
||||
}),
|
||||
secureExec(),
|
||||
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
|
||||
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
|
||||
// Fix: load the real node-stdlib-browser at build time (where require.resolve
|
||||
// works), capture the resolved path map, and inline it as a static export.
|
||||
esbuildPlugin({
|
||||
name: "node-stdlib-browser-stub",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
|
||||
path: "node-stdlib-browser",
|
||||
namespace: "nsb-resolved",
|
||||
}));
|
||||
build.onLoad({ filter: /.*/, namespace: "nsb-resolved" }, () => {
|
||||
const buildRequire = createRequire(import.meta.url);
|
||||
const resolved = buildRequire("node-stdlib-browser");
|
||||
return {
|
||||
contents: `export default ${JSON.stringify(resolved)};`,
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
}),
|
||||
// @secure-exec/node's bridge-loader.js runs require.resolve("@secure-exec/core")
|
||||
// at module scope to locate dist/bridge.js on disk. This fails in Trigger's
|
||||
// Docker container where the code is bundled into chunks and the package
|
||||
// isn't on disk. Fix: inline bridge.js content at build time so no runtime
|
||||
// filesystem access or package resolution is needed.
|
||||
esbuildPlugin({
|
||||
name: "inline-secure-exec-bridge",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
|
||||
(args) => {
|
||||
const buildRequire = createRequire(args.path);
|
||||
const coreEntry = buildRequire.resolve("@secure-exec/core");
|
||||
const coreRoot = path.resolve(path.dirname(coreEntry), "..");
|
||||
const bridgeCode = fs.readFileSync(path.join(coreRoot, "dist", "bridge.js"), "utf8");
|
||||
return {
|
||||
contents: [
|
||||
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
|
||||
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
|
||||
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
|
||||
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
|
||||
].join("\n"),
|
||||
loader: "js",
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
],
|
||||
external: [
|
||||
// esbuild must not be bundled — it locates its native binary via a
|
||||
// relative path from its JS API entry point. secure-exec uses esbuild
|
||||
// at runtime to bundle polyfills for sandbox code.
|
||||
"esbuild",
|
||||
],
|
||||
keepNames: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { prompts } from "@trigger.dev/sdk";
|
||||
import { streamText, createProviderRegistry } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const registry = createProviderRegistry({ openai });
|
||||
|
||||
type RegistryModelId = Parameters<typeof registry.languageModel>[0];
|
||||
|
||||
const systemPrompt = prompts.define({
|
||||
id: "test-agent-system",
|
||||
model: "openai:gpt-4o-mini" satisfies RegistryModelId,
|
||||
config: { temperature: 0.7 },
|
||||
variables: z.object({ userId: z.string() }),
|
||||
content: `You are a helpful AI assistant in the Trigger.dev playground.
|
||||
The current user is {{userId}}.
|
||||
|
||||
## Guidelines
|
||||
- Be concise and friendly. Prefer short, direct answers.
|
||||
- Use markdown formatting for code blocks and lists.
|
||||
- If you don't know something, say so.`,
|
||||
});
|
||||
|
||||
export const testAgent = chat
|
||||
.withClientData({
|
||||
schema: z.object({
|
||||
userId: z.string().optional().default("anonymous"),
|
||||
model: z.string().optional().default("openai:gpt-4o-mini"),
|
||||
}),
|
||||
})
|
||||
.onChatStart(async ({ clientData }) => {
|
||||
const resolved = await systemPrompt.resolve({
|
||||
userId: clientData?.userId ?? "anonymous",
|
||||
});
|
||||
chat.prompt.set(resolved);
|
||||
})
|
||||
.agent({
|
||||
id: "test-agent",
|
||||
run: async ({ messages, clientData, signal }) => {
|
||||
// chat.toStreamTextOptions({ registry }) resolves the prompt's model via
|
||||
// the registry and injects system prompt + telemetry automatically
|
||||
const model = registry.languageModel(clientData?.model ? (clientData.model as RegistryModelId) : "openai:gpt-4o-mini")
|
||||
|
||||
if (!model) {
|
||||
throw new Error("Model not found");
|
||||
}
|
||||
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions({ registry }),
|
||||
model,
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user