feat: Sessions primitive — durable run-aware streams + dashboard

Adds Sessions, a durable, run-aware stream primitive that scopes
session.in / session.out records to a session (not a single run).
Records survive run boundaries; reconnect-from-last-event-id is built in.

Server foundation:
- New /realtime/v1/sessions/:session/:io/append + /records routes
- sessionRunManager + sessionsRepository + clickhouseSessionsRepository
- mintRunToken for short-lived per-session tokens
- s2Append retry-with-backoff + undici cause diagnostics
- /api/v[12]/packets/* exempt from customer rate limits
- BackgroundWorker schema gains taskKind enum (TASK, AGENT, SCHEDULED)
- TaskRun.taskKind column + clickhouse 029_add_task_kind_to_task_runs_v2

Core types:
- new sessionStreams, inputStreams, realtimeStreams packages in @trigger.dev/core
- session-streams-api / realtime-streams-api surface

Sessions dashboard UI (the primitive's own viewer):
- /sessions index + detail routes
- SessionsTable, SessionFilters, SessionStatus, CloseSessionDialog
- AGENT/SCHEDULED filter in RunFilters + TaskTriggerSource

Includes the sessions-primitive changeset.
This commit is contained in:
Eric Allam
2026-05-10 22:26:11 +01:00
parent 8ba067d8b0
commit be1a6cf8de
96 changed files with 6290 additions and 204 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add `ChatChunkTooLargeError` and ApiClient methods for subscribing to session streams. Lays the groundwork for the upcoming `chat.agent`.
+9
View File
@@ -0,0 +1,9 @@
---
"@trigger.dev/sdk": minor
"@trigger.dev/core": patch
---
Adds the Sessions primitive — a durable, run-aware stream channel keyed
on a stable `externalId`. Public SDK additions: `tasks.triggerAndSubscribe()`
and the `chat.agent` runtime built on top of Sessions. See
https://trigger.dev/docs/ai-chat/overview for the full feature surface.
+4
View File
@@ -65,6 +65,10 @@ apps/**/public/build
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
**/.claude/settings.local.json
.claude/architecture/
.claude/docs-plans/
.claude/review-guides/
.claude/scheduled_tasks.lock
.mcp.log
.mcp.json
.cursor/debug.log
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
New Sessions page in the dashboard for inspecting `chat.agent` Session rows alongside their underlying runs, plus a "Task source" filter on the Runs list (Standard / Scheduled / Agent) so agent runs can be sliced out of mixed workloads at a glance.
+2
View File
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. Su
This is a pnpm 10.33.2 monorepo using Turborepo. Run commands from root with `pnpm run`.
**Adding dependencies:** Edit `package.json` directly instead of using `pnpm add`, then run `pnpm i` from the repo root. See `.claude/rules/package-installation.md` for the full process.
```bash
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
pnpm run db:migrate # Run database migrations
@@ -1,4 +1,5 @@
import {
ArrowsRightLeftIcon,
BeakerIcon,
BellAlertIcon,
BookOpenIcon,
@@ -189,6 +190,28 @@ export function BatchesNone() {
);
}
export function SessionsNone() {
return (
<InfoPanel
title="Sessions"
icon={ArrowsRightLeftIcon}
iconClassName="text-teal-500"
panelClassName="max-w-full"
accessory={
<LinkButton to={docsPath("/ai-chat/overview")} variant="docs/small" LeadingIcon={BookOpenIcon}>
Sessions docs
</LinkButton>
}
>
<Paragraph spacing variant="small">
You have no sessions in this environment. Sessions are durable, typed, bidirectional I/O
primitives that outlive a single run — used by <InlineCode>chat.agent</InlineCode> and any
long-running task that needs streaming input and output.
</Paragraph>
</InfoPanel>
);
}
export function TestHasNoTasks() {
const organization = useOrganization();
const project = useProject();
@@ -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);
}
@@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react";
import {
CalendarIcon,
ClockIcon,
CpuChipIcon,
FingerPrintIcon,
PlusIcon,
RectangleStackIcon,
@@ -190,6 +191,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>;
@@ -231,6 +235,8 @@ export function filterTitle(filterKey: string) {
return "Version";
case "errorId":
return "Error ID";
case "sources":
return "Source";
default:
return filterKey;
}
@@ -271,6 +277,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;
}
@@ -318,6 +326,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);
@@ -359,7 +371,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.5">
@@ -395,6 +408,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"];
@@ -448,6 +462,7 @@ function AppliedFilters({ bulkActions }: RunFiltersProps) {
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
<AppliedErrorIdFilter />
<AppliedSourceFilter />
</>
);
}
@@ -482,6 +497,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} />;
}
}
@@ -1739,3 +1756,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;
@@ -352,6 +354,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";
@@ -12,13 +12,20 @@ export function TaskTriggerSourceIcon({
}) {
switch (source) {
case "STANDARD": {
return <TaskIconSmall className="size-[1.125rem] min-w-[1.125rem] text-tasks" />;
return (
<TaskIconSmall className={cn("size-[1.125rem] min-w-[1.125rem] text-tasks", className)} />
);
}
case "SCHEDULED": {
return (
<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 +37,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
case "SCHEDULED": {
return "Scheduled task";
}
case "AGENT": {
return "Agent";
}
}
}
@@ -0,0 +1,72 @@
import { XCircleIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type CloseSessionDialogProps = {
sessionParam: string;
environmentId: string;
redirectPath: string;
};
export function CloseSessionDialog({
sessionParam,
environmentId,
redirectPath,
}: CloseSessionDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="close-session">
<DialogHeader>Close this session?</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Closing a session is permanent. The session will no longer accept new input or trigger
new runs. Any in-flight run continues until it finishes on its own.
</Paragraph>
<Form action={formAction} method="post" className="flex flex-col gap-3">
<input type="hidden" name="redirectUrl" value={redirectPath} />
<input type="hidden" name="environmentId" value={environmentId} />
<div className="flex flex-col gap-1">
<Label htmlFor="close-session-reason">Reason (optional)</Label>
<Input
id="close-session-reason"
name="reason"
placeholder="e.g. user signed out, ticket resolved"
variant="medium"
spellCheck={false}
autoFocus
/>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : XCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Closing..." : "Close session"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</Form>
</div>
</DialogContent>
);
}
@@ -0,0 +1,764 @@
import * as Ariakit from "@ariakit/react";
import {
CpuChipIcon,
FingerPrintIcon,
TagIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { ListFilterIcon } from "lucide-react";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { StatusIcon } from "~/assets/icons/StatusIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
import {
appliedSummary,
FilterMenuProvider,
TimeFilter,
} from "../../runs/v3/SharedFilters";
import {
allSessionStatuses,
descriptionForSessionStatus,
SessionStatusCombo,
sessionStatusTitle,
} from "./SessionStatus";
const StringOrStringArray = z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.array(z.string()).optional()
);
export const SessionStatus = z.enum(allSessionStatuses);
export const SessionListSearchFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
SessionStatus.array().optional()
),
types: StringOrStringArray,
taskIdentifiers: StringOrStringArray,
externalId: z.string().optional(),
tags: StringOrStringArray,
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type SessionListSearchFilters = z.infer<typeof SessionListSearchFilters>;
export type SessionListSearchFilterKey = keyof SessionListSearchFilters;
export function getSessionFiltersFromSearchParams(
searchParams: URLSearchParams
): SessionListSearchFilters {
function listOrUndefined(key: string) {
const values = searchParams.getAll(key).filter((v) => v.length > 0);
return values.length > 0 ? values : undefined;
}
const params = {
cursor: searchParams.get("cursor") ?? undefined,
direction: searchParams.get("direction") ?? undefined,
statuses: listOrUndefined("statuses"),
types: listOrUndefined("types"),
taskIdentifiers: listOrUndefined("taskIdentifiers"),
externalId: searchParams.get("externalId") ?? undefined,
tags: listOrUndefined("tags"),
period: searchParams.get("period") ?? undefined,
from: searchParams.get("from") ?? undefined,
to: searchParams.get("to") ?? undefined,
};
const parsed = SessionListSearchFilters.safeParse(params);
if (!parsed.success) {
return {};
}
return parsed.data;
}
type SessionFiltersProps = {
hasFilters: boolean;
possibleTypes?: string[];
};
export function SessionFilters(props: SessionFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("types") ||
searchParams.has("taskIdentifiers") ||
searchParams.has("externalId") ||
searchParams.has("tags");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<TimeFilter />
<AppliedFilters />
{hasFilters && (
<Form className="h-6">
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: <StatusIcon className="size-4 border-text-bright" />,
},
{ name: "types", title: "Type", icon: <CpuChipIcon className="size-4" /> },
{
name: "taskIdentifiers",
title: "Task",
icon: <TaskIcon className="size-4" />,
},
{
name: "externalId",
title: "External ID",
icon: <FingerPrintIcon className="size-4" />,
},
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: SessionFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"secondary/small"}
shortcut={shortcut}
tooltipTitle={"Filter sessions"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters() {
return (
<>
<AppliedStatusFilter />
<AppliedTypeFilter />
<AppliedTaskIdentifierFilter />
<AppliedExternalIdFilter />
<AppliedTagsFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & SessionFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "types":
return <TypeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "taskIdentifiers":
return (
<TaskIdentifierDropdown onClose={() => props.setFilterType(undefined)} {...props} />
);
case "externalId":
return <ExternalIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statusItems = allSessionStatuses.map((status) => ({
title: sessionStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (next: string[]) => {
clearSearchValue();
replace({ statuses: next, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statusItems.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} 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 status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<SessionStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForSessionStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) return null;
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={<StatusIcon className="size-3.5" />}
value={appliedSummary(
statuses.map((v) => sessionStatusTitle(v as (typeof allSessionStatuses)[number]))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function TypeDropdown({
trigger,
searchValue,
clearSearchValue,
possibleTypes,
onClose,
}: {
trigger: ReactNode;
searchValue: string;
clearSearchValue: () => void;
possibleTypes?: string[];
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (next: string[]) => {
clearSearchValue();
replace({ types: next, cursor: undefined, direction: undefined });
};
const items = useMemo(() => {
const all = possibleTypes && possibleTypes.length > 0 ? possibleTypes : ["chat"];
const seen = new Set(all);
for (const v of values("types")) {
if (!seen.has(v)) {
all.push(v);
seen.add(v);
}
}
return all.filter((t) => t.toLowerCase().includes(searchValue.toLowerCase()));
}, [possibleTypes, searchValue, values]);
return (
<SelectProvider value={values("types")} 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 type..."} value={searchValue} />
<SelectList>
{items.map((value, index) => (
<SelectItem
key={value}
value={value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<span className="font-mono text-xs">{value}</span>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTypeFilter() {
const { values, del } = useSearchParams();
const types = values("types");
if (types.length === 0) return null;
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TypeDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Type"
icon={<CpuChipIcon className="size-3.5" />}
value={appliedSummary(types)}
onRemove={() => del(["types", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function TaskIdentifierDropdown({
trigger,
searchValue,
clearSearchValue,
onClose,
}: {
trigger: ReactNode;
searchValue: string;
clearSearchValue: () => void;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const current = value("taskIdentifiers");
const [draft, setDraft] = useState(current ?? "");
const apply = useCallback(() => {
clearSearchValue();
replace({
taskIdentifiers: draft.trim() === "" ? undefined : [draft.trim()],
cursor: undefined,
direction: undefined,
});
setOpen(false);
}, [clearSearchValue, draft, replace]);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Task identifier</Label>
<Input
placeholder="my-task"
value={draft}
onChange={(e) => setDraft(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={apply}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTaskIdentifierFilter() {
const { values, del } = useSearchParams();
const taskIdentifiers = values("taskIdentifiers");
if (taskIdentifiers.length === 0) return null;
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TaskIdentifierDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
icon={<TaskIcon className="size-3.5" />}
value={appliedSummary(taskIdentifiers)}
onRemove={() => del(["taskIdentifiers", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function ExternalIdDropdown({
trigger,
searchValue,
clearSearchValue,
onClose,
}: {
trigger: ReactNode;
searchValue: string;
clearSearchValue: () => void;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const current = value("externalId");
const [draft, setDraft] = useState(current ?? "");
const apply = useCallback(() => {
clearSearchValue();
replace({
externalId: draft.trim() === "" ? undefined : draft.trim(),
cursor: undefined,
direction: undefined,
});
setOpen(false);
}, [clearSearchValue, draft, replace]);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(36ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>External ID</Label>
<Input
placeholder="user-supplied id"
value={draft}
onChange={(e) => setDraft(e.target.value)}
variant="small"
className="w-[33ch] font-mono"
spellCheck={false}
/>
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={apply}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedExternalIdFilter() {
const { value, del } = useSearchParams();
const externalId = value("externalId");
if (!externalId) return null;
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ExternalIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="External ID"
icon={<FingerPrintIcon className="size-3.5" />}
value={externalId}
onRemove={() => del(["externalId", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function TagsDropdown({
trigger,
searchValue,
clearSearchValue,
onClose,
}: {
trigger: ReactNode;
searchValue: string;
clearSearchValue: () => void;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { values, replace } = useSearchParams();
const current = values("tags");
const [draft, setDraft] = useState(current.join(", "));
const apply = useCallback(() => {
clearSearchValue();
const next = draft
.split(/[,\n]/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
replace({
tags: next.length === 0 ? undefined : next,
cursor: undefined,
direction: undefined,
});
setOpen(false);
}, [clearSearchValue, draft, replace]);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(40ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Tags</Label>
<Input
placeholder="tag1, tag2"
value={draft}
onChange={(e) => setDraft(e.target.value)}
variant="small"
className="w-[37ch] font-mono"
spellCheck={false}
/>
<Paragraph variant="extra-small/dimmed">
Comma-separated. Matches sessions with any of these tags.
</Paragraph>
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="secondary/small"
shortcut={{
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={apply}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTagsFilter() {
const { values, del } = useSearchParams();
const tags = values("tags");
if (tags.length === 0) return null;
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TagsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Tags"
icon={<TagIcon className="size-3.5" />}
value={appliedSummary(tags)}
onRemove={() => del(["tags", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,89 @@
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
import assertNever from "assert-never";
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
import { cn } from "~/utils/cn";
export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly<
Array<SessionStatus>
>;
const descriptions: Record<SessionStatus, string> = {
ACTIVE: "The session is open and can receive input or schedule new runs.",
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
EXPIRED: "The session passed its expiry time without being closed explicitly.",
};
export function descriptionForSessionStatus(status: SessionStatus): string {
return descriptions[status];
}
export function sessionStatusTitle(status: SessionStatus): string {
switch (status) {
case "ACTIVE":
return "Active";
case "CLOSED":
return "Closed";
case "EXPIRED":
return "Expired";
default:
assertNever(status);
}
}
export function sessionStatusColor(status: SessionStatus): string {
switch (status) {
case "ACTIVE":
return "text-pending";
case "CLOSED":
return "text-success";
case "EXPIRED":
return "text-text-dimmed";
default:
assertNever(status);
}
}
export function SessionStatusIcon({
status,
className,
}: {
status: SessionStatus;
className: string;
}) {
switch (status) {
case "ACTIVE":
return (
<span className={cn("inline-flex items-center justify-center", className)}>
<span className="size-2 rounded-full bg-pending" />
</span>
);
case "CLOSED":
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
case "EXPIRED":
return <ClockIcon className={cn(sessionStatusColor(status), className)} />;
default:
assertNever(status);
}
}
export function SessionStatusLabel({ status }: { status: SessionStatus }) {
return <span className={sessionStatusColor(status)}>{sessionStatusTitle(status)}</span>;
}
export function SessionStatusCombo({
status,
className,
iconClassName,
}: {
status: SessionStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<SessionStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<SessionStatusLabel status={status} />
</span>
);
}
@@ -0,0 +1,224 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { useLocation, useNavigation } from "@remix-run/react";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { ListBulletIcon } from "~/assets/icons/ListBulletIcon";
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
import { DateTime } from "~/components/primitives/DateTime";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
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 { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunTag } from "~/components/runs/v3/RunTag";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import {
type SessionListItem,
type SessionList,
} from "~/presenters/v3/SessionListPresenter.server";
import { v3RunPath, v3RunsPath, v3SessionPath } from "~/utils/pathBuilder";
import {
descriptionForSessionStatus,
SessionStatusCombo,
allSessionStatuses,
} from "./SessionStatus";
type SessionsTableProps = Pick<SessionList, "sessions" | "filters" | "hasFilters">;
export function SessionsTable({ sessions, hasFilters }: SessionsTableProps) {
const navigation = useNavigation();
const location = useLocation();
const isLoading =
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{allSessionStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[6rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<SessionStatusCombo status={status} iconClassName="animate-none" />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
{descriptionForSessionStatus(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Tags</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{sessions.length === 0 ? (
<TableBlankRow colSpan={8}>
<div className="flex items-center justify-center">
<Paragraph className="w-auto">
{hasFilters
? "No sessions match these filters"
: "No sessions in this environment yet"}
</Paragraph>
</div>
</TableBlankRow>
) : (
sessions.map((session) => {
const runPath = session.currentRunFriendlyId
? v3RunPath(organization, project, environment, {
friendlyId: session.currentRunFriendlyId,
})
: undefined;
const displayId = session.externalId ?? session.friendlyId;
const sessionPath = v3SessionPath(organization, project, environment, {
friendlyId: session.friendlyId,
});
const allRunsPath = v3RunsPath(organization, project, environment, {
tags: [`chat:${displayId}`],
});
return (
<TableRow key={session.id}>
<TableCell to={sessionPath} isTabbableCell>
<div className="w-[28ch]">
<MiddleTruncate text={displayId} className="font-mono text-xs" />
</div>
</TableCell>
<TableCell to={sessionPath}>
<SimpleTooltip
content={descriptionForSessionStatus(session.status)}
disableHoverableContent
button={<SessionStatusCombo status={session.status} />}
/>
</TableCell>
<TableCell to={sessionPath}>
<span className="font-mono text-xs">{session.type}</span>
</TableCell>
<TableCell to={sessionPath}>
<div className="w-[24ch]">
<MiddleTruncate
text={session.taskIdentifier}
className="font-mono text-xs"
/>
</div>
</TableCell>
<TableCell to={sessionPath}>
{session.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{session.tags.map((tag) => (
<RunTag key={tag} tag={tag} />
))}
</div>
) : (
<span className="text-text-dimmed"></span>
)}
</TableCell>
<TableCell to={sessionPath}>
<DateTime date={session.createdAt} />
</TableCell>
<TableCell
to={sessionPath}
className="w-[1%]"
actionClassName="pr-0 tabular-nums"
>
<SessionDuration session={session} />
</TableCell>
<SessionActionsCell runPath={runPath} allRunsPath={allRunsPath} />
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function SessionDuration({ session }: { session: SessionListItem }) {
// Active sessions tick live; closed/expired sessions freeze at the
// moment they ended (closedAt for explicit closes, expiresAt when the
// TTL ran out without a close call).
const endedAt =
session.status === "CLOSED"
? session.closedAt
: session.status === "EXPIRED"
? session.expiresAt
: undefined;
if (endedAt) {
return <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>;
}
return <LiveTimer startTime={new Date(session.createdAt)} />;
}
function SessionActionsCell({
runPath,
allRunsPath,
}: {
runPath?: string;
allRunsPath: string;
}) {
return (
<TableCellMenu
isSticky
popoverContent={
<>
{runPath && (
<PopoverMenuItem
to={runPath}
icon={ArrowRightIcon}
leadingIconClassName="text-runs"
title="View current run"
/>
)}
<PopoverMenuItem
to={allRunsPath}
icon={ListBulletIcon}
leadingIconClassName="text-runs"
title="View all runs"
/>
</>
}
/>
);
}
@@ -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,18 @@
import {
getSessionFiltersFromSearchParams,
SessionListSearchFilters,
} from "~/components/sessions/v1/SessionFilters";
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
export type SessionFiltersFromRequest = SessionListSearchFilters & {
statuses?: SessionStatus[];
};
export function getSessionFiltersFromRequest(request: Request): SessionFiltersFromRequest {
const url = new URL(request.url);
const s = getSessionFiltersFromSearchParams(url.searchParams);
return {
...s,
statuses: s.statuses as SessionStatus[] | undefined,
};
}
@@ -304,6 +304,10 @@ export class ApiRunListPresenter extends BasePresenter {
durationMs: run.usageDurationMs,
depth: run.depth,
metadata,
// ClickHouse defaults `task_kind` to "" for pre-migration rows.
// Match `NextRunListPresenter`'s "STANDARD" fallback so API
// consumers and the dashboard see the same value.
taskKind: run.taskKind || "STANDARD",
...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,227 @@
import { type Span } from "@opentelemetry/api";
import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import {
type SessionStatus,
SessionsRepository,
} from "~/services/sessionsRepository/sessionsRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { startActiveSpan } from "~/v3/tracer.server";
export type SessionListOptions = {
userId?: string;
projectId: string;
// filters
types?: string[];
taskIdentifiers?: string[];
externalId?: string;
tags?: string[];
statuses?: SessionStatus[];
period?: string;
from?: number;
to?: number;
// pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type SessionList = Awaited<ReturnType<SessionListPresenter["call"]>>;
export type SessionListItem = SessionList["sessions"][0];
export type SessionListAppliedFilters = SessionList["filters"];
export class SessionListPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
public async call(
organizationId: string,
environmentId: string,
options: SessionListOptions
) {
return startActiveSpan(
"SessionListPresenter.call",
(span) => this.#call(organizationId, environmentId, options, span),
{
attributes: {
organizationId,
environmentId,
projectId: options.projectId,
},
}
);
}
async #call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
types,
taskIdentifiers,
externalId,
tags,
statuses,
period,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: SessionListOptions,
rootSpan: Span
) {
const time = timeFilters({ period, from, to });
const hasFilters =
(types !== undefined && types.length > 0) ||
(taskIdentifiers !== undefined && taskIdentifiers.length > 0) ||
(externalId !== undefined && externalId !== "") ||
(tags !== undefined && tags.length > 0) ||
(statuses !== undefined && statuses.length > 0) ||
!time.isDefault;
rootSpan.setAttribute("filters.hasFilters", hasFilters);
rootSpan.setAttribute("page.size", pageSize);
if (cursor) rootSpan.setAttribute("page.cursor", cursor);
const displayableEnvironment = await startActiveSpan(
"SessionListPresenter.findDisplayableEnvironment",
() => findDisplayableEnvironment(environmentId, userId)
);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
const sessionsRepository = new SessionsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
});
function clampToNow(date: Date): Date {
const now = new Date();
return date > now ? now : date;
}
const { sessions, pagination } = await sessionsRepository.listSessions({
organizationId,
projectId,
environmentId,
types,
taskIdentifiers,
externalId,
tags,
statuses,
period,
from: time.from ? time.from.getTime() : undefined,
to: time.to ? clampToNow(time.to).getTime() : undefined,
page: {
size: pageSize,
cursor,
direction,
},
});
rootSpan.setAttribute("page.count", sessions.length);
let hasAnySessions = sessions.length > 0;
if (!hasAnySessions) {
const firstSession = await startActiveSpan(
"SessionListPresenter.hasAnySessions",
() =>
this.replica.session.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
})
);
if (firstSession) {
hasAnySessions = true;
}
}
// Resolve current-run friendlyIds in one query so each row can link to
// its live run. Status is intentionally not joined yet — that lives in
// ClickHouse and would mean a second query per page; the link itself
// is the value most viewers want first.
const currentRunIds = sessions
.map((s) => s.currentRunId)
.filter((id): id is string => Boolean(id));
const currentRuns = await startActiveSpan(
"SessionListPresenter.findCurrentRuns",
async (span) => {
span.setAttribute("currentRunIds.count", currentRunIds.length);
// Scope by projectId + runtimeEnvironmentId — Session.currentRunId
// is a plain string column without an FK, so a stale or corrupted
// pointer could surface another tenant's run. The list query above
// is already env-scoped; the run lookup needs the same fence.
return currentRunIds.length > 0
? this.replica.taskRun.findMany({
where: {
id: { in: currentRunIds },
projectId,
runtimeEnvironmentId: environmentId,
},
select: { id: true, friendlyId: true },
})
: [];
}
);
const runById = new Map(currentRuns.map((r) => [r.id, r] as const));
const now = Date.now();
return {
sessions: sessions.map((session) => {
const status: SessionStatus =
session.closedAt != null
? "CLOSED"
: session.expiresAt != null && session.expiresAt.getTime() < now
? "EXPIRED"
: "ACTIVE";
const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
return {
id: session.id,
friendlyId: session.friendlyId,
externalId: session.externalId,
type: session.type,
taskIdentifier: session.taskIdentifier,
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
status,
closedAt: session.closedAt ? session.closedAt.toISOString() : undefined,
closedReason: session.closedReason ?? undefined,
expiresAt: session.expiresAt ? session.expiresAt.toISOString() : undefined,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
environment: displayableEnvironment,
currentRunFriendlyId: currentRun?.friendlyId,
};
}),
pagination: {
next: pagination.nextCursor ?? undefined,
previous: pagination.previousCursor ?? undefined,
},
filters: {
types: types ?? [],
taskIdentifiers: taskIdentifiers ?? [],
externalId,
tags: tags ?? [],
statuses: statuses ?? [],
from: time.from,
to: time.to,
},
hasFilters,
hasAnySessions,
};
}
}
@@ -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"
);
}
}
}
@@ -373,6 +373,10 @@ export class TestTaskPresenter {
),
};
}
case "AGENT": {
// AGENT tasks are filtered out by TestPresenter and shouldn't reach here
return { foundTask: false };
}
default: {
return task.triggerSource satisfies never;
}
@@ -0,0 +1,107 @@
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { SessionFilters } from "~/components/sessions/v1/SessionFilters";
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
import { SessionsNone } from "~/components/BlankStatePanels";
import { $replica } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server";
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Sessions | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Error("Environment not found");
}
const filters = getSessionFiltersFromRequest(request);
const presenter = new SessionListPresenter($replica, clickhouseClient);
const list = await presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
statuses: filters.statuses,
types: filters.types,
taskIdentifiers: filters.taskIdentifiers,
externalId: filters.externalId,
tags: filters.tags,
period: filters.period,
from: filters.from,
to: filters.to,
cursor: filters.cursor,
direction: filters.direction,
});
return typedjson(list);
};
export default function Page() {
const list = useTypedLoaderData<typeof loader>();
return (
<>
<NavBar>
<PageTitle title="Sessions" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/ai-chat/overview")}
>
Sessions docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{!list.hasAnySessions ? (
<MainCenteredContainer className="max-w-md">
<SessionsNone />
</MainCenteredContainer>
) : (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<SessionFilters hasFilters={list.hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination: list.pagination }} />
</div>
</div>
<SessionsTable
sessions={list.sessions}
filters={list.filters}
hasFilters={list.hasFilters}
/>
</div>
)}
</PageBody>
</>
);
}
@@ -0,0 +1,10 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -0,0 +1,54 @@
import { json } from "@remix-run/server-runtime";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
{
allowJWT: true,
corsStrategy: "none",
authorization: {
action: "read",
resource: () => ({ type: "deployments", id: "current" }),
},
findResource: async (_params, auth) => {
const promotion = await $replica.workerDeploymentPromotion.findFirst({
where: {
environmentId: auth.environment.id,
label: "current",
},
select: {
deployment: {
select: {
friendlyId: true,
createdAt: true,
shortCode: true,
version: true,
runtime: true,
runtimeVersion: true,
status: true,
deployedAt: true,
git: true,
errorData: true,
},
},
},
});
return promotion?.deployment ?? undefined;
},
},
async ({ resource: deployment }) => {
return json({
id: deployment.friendlyId,
createdAt: deployment.createdAt,
shortCode: deployment.shortCode,
version: deployment.version,
runtime: deployment.runtime,
runtimeVersion: deployment.runtimeVersion,
status: deployment.status,
deployedAt: deployment.deployedAt ?? undefined,
git: deployment.git ?? undefined,
error: deployment.errorData ?? undefined,
});
}
);
@@ -0,0 +1,98 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
import {
canonicalSessionAddressingKey,
isSessionFriendlyIdForm,
resolveSessionByIdOrExternalId,
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
session: z.string(),
io: z.enum(["out", "in"]),
});
const SearchSchema = z.object({
// S2 sequence number — same cursor format as the SSE Last-Event-ID
// (the SSE `id:` field on session-channel events is the seq_num,
// stringified). Records returned have `seqNum > afterEventId`.
afterEventId: z.string().regex(/^\d+$/).optional(),
});
// GET: non-SSE, `wait=0` drain of a session channel. Returns a JSON body
// `{ records: StreamRecord[] }` with whatever records exist after
// `afterEventId` (or from the head if absent) and closes immediately.
//
// Used by the SDK's `replaySessionOutTail` at run boot — the SSE long-poll
// path costs ~1s per fresh chat (the timeout duration) regardless of stream
// content, which is unacceptable on the first-message TTFC budget. This
// route gives the agent a cheap "what's there right now" peek instead.
//
// Same row-optional addressing as the SSE GET route in `…$io.ts`: we
// resolve via `resolveSessionByIdOrExternalId` and only 404 for opaque
// `session_*` friendlyIds (which must reference a real row). External-id
// form falls through with `row: null` so the boot path doesn't 404 on a
// fresh chat that hasn't written its first chunk yet.
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
searchParams: SearchSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) => {
const row = await resolveSessionByIdOrExternalId(
$replica,
auth.environment.id,
params.session
);
if (!row && isSessionFriendlyIdForm(params.session)) {
return undefined;
}
return {
row,
addressingKey: canonicalSessionAddressingKey(row, params.session),
};
},
authorization: {
action: "read",
// Multi-key: the channel is addressable by the URL key, the row's
// friendlyId, and (if set) externalId. Type-level `read:sessions`
// matches any of them; `read:all` / `admin` bypass via the JWT
// ability's wildcard branches.
resource: ({ row, addressingKey }) => {
const ids = new Set<string>([addressingKey]);
if (row) {
ids.add(row.friendlyId);
if (row.externalId) ids.add(row.externalId);
}
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
},
},
async ({ params, authentication, resource, searchParams }) => {
const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", {
session: resource.row,
organization: resource.row ? null : authentication.environment.organization,
});
if (!(realtimeStream instanceof S2RealtimeStreams)) {
return new Response("Session channels require the S2 realtime backend", {
status: 501,
});
}
const afterSeqNum =
searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined;
const records = await realtimeStream.readSessionStreamRecords(
resource.addressingKey,
params.io,
afterSeqNum
);
return json({ records });
}
);
@@ -87,7 +87,7 @@ export const loader = createLoaderApiRoute(
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) => {
return $replica.taskRun.findFirst({
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
@@ -106,6 +106,7 @@ export const loader = createLoaderApiRoute(
},
},
});
return run;
},
authorization: {
action: "read",
@@ -0,0 +1,98 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
export const closeSessionSchema = z.object({
redirectUrl: z.string(),
environmentId: z.string(),
reason: z.string().optional(),
});
const ParamSchema = z.object({
sessionParam: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { sessionParam } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: closeSessionSchema });
if (!submission.value) {
return json(submission);
}
const { redirectUrl, environmentId, reason } = submission.value;
const trimmedReason = reason?.trim();
const closedReason =
trimmedReason && trimmedReason.length > 0 ? trimmedReason : "closed-from-dashboard";
try {
// Confirm the user belongs to the org that owns this environment, then
// resolve the session by friendlyId or externalId scoped to that env.
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: environmentId,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
if (!environment) {
submission.error = { environmentId: ["Environment not found"] };
return json(submission);
}
const session = await resolveSessionByIdOrExternalId(
$replica,
environment.id,
sessionParam
);
if (!session) {
submission.error = { sessionParam: ["Session not found"] };
return json(submission);
}
if (session.closedAt) {
// Already closed — no-op, but redirect with a friendly message so the
// UI doesn't look like it did nothing.
return redirectWithSuccessMessage(redirectUrl, request, `Session already closed`);
}
// Conditional update mirrors the public API: two concurrent closes race
// through the read but only one wins this update.
await prisma.session.updateMany({
where: { id: session.id, closedAt: null },
data: {
closedAt: new Date(),
closedReason,
},
});
return redirectWithSuccessMessage(redirectUrl, request, `Closed session`);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to close session", {
error: { name: error.name, message: error.message, stack: error.stack },
});
return redirectWithErrorMessage(
redirectUrl,
request,
`Failed to close session, ${error.message}`
);
}
logger.error("Failed to close session", { error });
return redirectWithErrorMessage(
redirectUrl,
request,
`Failed to close session, ${JSON.stringify(error)}`
);
}
};
+11 -1
View File
@@ -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) {
@@ -106,19 +107,26 @@ export class DefaultQueueManager implements QueueManager {
queueName = specifiedQueue.name;
lockedQueueId = specifiedQueue.id;
// Only fetch task for TTL if caller didn't provide a per-trigger TTL
if (request.body.options?.ttl === undefined) {
const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({
where: {
workerId: lockedBackgroundWorker.id,
runtimeEnvironmentId: request.environment.id,
slug: request.taskId,
},
select: { ttl: true },
});
// Always fetch the task so we can resolve `triggerSource` (which
// becomes `taskKind` on annotations and replicates to ClickHouse).
// Without this, AGENT/SCHEDULED runs triggered with
// `lockToVersion` + a queue override would be annotated as
// STANDARD and disappear from the run-list "Source" filter.
// `ttl` is read from the same row but only used when the caller
// didn't specify a per-trigger TTL.
const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({
where: {
workerId: lockedBackgroundWorker.id,
runtimeEnvironmentId: request.environment.id,
slug: request.taskId,
},
select: { ttl: true, triggerSource: true },
});
if (request.body.options?.ttl === undefined) {
taskTtl = lockedTask?.ttl;
}
taskKind = lockedTask?.triggerSource;
} else {
// No queue override - fetch task with queue to get both default queue and TTL
const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({
@@ -158,6 +166,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 +181,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 +198,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 ?? {};
@@ -202,14 +213,21 @@ export class DefaultQueueManager implements QueueManager {
const defaultQueueName = `task/${taskId}`;
// When caller provides both a queue override and a per-trigger TTL,
// we don't need any DB queries - the per-trigger TTL takes precedence
if (overriddenQueueName && body.options?.ttl !== undefined) {
return { queueName: overriddenQueueName, taskTtl: undefined };
}
// Even when the caller provides both a queue override and a
// per-trigger TTL, we still need to fetch the task so `triggerSource`
// (which becomes `taskKind` on annotations and replicates to
// ClickHouse) is populated. Without it, AGENT/SCHEDULED runs hitting
// this path get stamped as STANDARD and disappear from the
// dashboard's `Source` filter. Mirrors the locked-worker fix above
// — `taskTtl` is harmless in the returned value because the call
// site coalesces `body.options.ttl ?? taskTtl`.
// Find the current worker for the environment
const worker = await findCurrentWorkerFromEnvironment(environment, this.prisma);
// Find the current worker for the environment. Replica is fine here —
// the adjacent `backgroundWorkerTask` lookups below already use
// `replicaPrisma` (replica lag for "just deployed" is bounded the same
// way for both queries; reading the worker from the writer and the
// task from the replica would only widen the inconsistency window).
const worker = await findCurrentWorkerFromEnvironment(environment, this.replicaPrisma);
if (!worker) {
logger.debug("Failed to get queue name: No worker found", {
@@ -228,10 +246,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 +279,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
+18 -17
View File
@@ -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;
@@ -63,6 +63,13 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
/^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/*
// Internal SDK plumbing — packets are presigned-URL handshakes for
// payload uploads (v2 PUT) and downloads (v1 GET), authenticated via
// run-scoped JWT, called once per task/turn boundary by the runtime.
// Same shape as `/api/v1/runs/$runFriendlyId/attempts` above; not a
// customer-facing surface so customer rate limits shouldn't apply.
/^\/api\/v1\/packets\//,
/^\/api\/v2\/packets\//,
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
@@ -0,0 +1,41 @@
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { extractJwtSigningSecretKey } from "./jwtAuth.server";
type Environment = Parameters<typeof extractJwtSigningSecretKey>[0];
export type MintRunTokenOptions = {
/** Include the input-stream write scope (needed for steering messages from the playground). */
includeInputStreamWrite?: boolean;
/** Token expiration. Defaults to "1h". */
expirationTime?: string;
};
/**
* Mint a run-scoped public access token (JWT) for browser subscription to a
* run's realtime streams.
*
* Used by:
* - The playground action to give a freshly triggered chat session a token.
* - The run details page to let the agent view subscribe to the chat stream
* of an existing run (read-only).
*/
export async function mintRunToken(
environment: Environment,
runFriendlyId: string,
options: MintRunTokenOptions = {}
): Promise<string> {
const scopes = [`read:runs:${runFriendlyId}`];
if (options.includeInputStreamWrite) {
scopes.push(`write:inputStreams:${runFriendlyId}`);
}
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes,
},
expirationTime: options.expirationTime ?? "1h",
});
}
@@ -441,8 +441,16 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
// ---------- Internals: S2 REST ----------
private async s2Append(stream: string, body: S2AppendInput): Promise<S2AppendAck> {
// POST /v1/streams/{stream}/records (JSON)
const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`, {
// POST /v1/streams/{stream}/records (JSON).
//
// Retries transient failures (network errors and 5xx) up to 3 times with
// exponential backoff. Undici's "fetch failed" errors observed locally
// are pre-connection (DNS/TCP) so the request never reaches S2, making
// retry safe — the alternative is a 500 surfacing to the SDK transport,
// which then retries the whole `/in/append` round-trip and pollutes
// logs. 4xx are not retried (genuine client errors).
const url = `${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`;
const init: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
@@ -451,12 +459,60 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
"S2-Basin": this.basin,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`);
};
const maxAttempts = 3;
const backoffsMs = [100, 250, 600];
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// The `try` only wraps `fetch` — once we have a Response we handle status
// outside the catch, so a 4xx throw can't be swallowed and retried.
let res: Response | undefined;
try {
res = await fetch(url, init);
} catch (err) {
lastError = err;
}
if (res) {
if (res.ok) {
return (await res.json()) as S2AppendAck;
}
const text = await res.text().catch(() => "");
const httpError = new Error(
`S2 append failed: ${res.status} ${res.statusText} ${text}`
);
if (res.status >= 400 && res.status < 500) {
// 4xx — caller-side problem (auth, malformed body, closed stream).
// Retrying won't help.
throw httpError;
}
// 5xx — retryable.
lastError = httpError;
}
const isLastAttempt = attempt === maxAttempts - 1;
const diagnostics = describeFetchError(lastError);
if (isLastAttempt) {
this.logger.error("S2 append failed after retries", {
stream,
attempts: maxAttempts,
...diagnostics,
});
break;
}
this.logger.warn("S2 append transient failure, retrying", {
stream,
attempt: attempt + 1,
nextDelayMs: backoffsMs[attempt],
...diagnostics,
});
await new Promise((resolve) => setTimeout(resolve, backoffsMs[attempt]));
}
return (await res.json()) as S2AppendAck;
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private async getS2AccessToken(id: string): Promise<string> {
@@ -560,3 +616,40 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
return Number.isFinite(n) && n >= 0 ? n + 1 : undefined;
}
}
// Pulls the underlying network error out of undici's generic "fetch failed".
// undici sets `error.cause` to either a SystemError-shaped object with `code`
// (e.g. `ECONNRESET`, `UND_ERR_SOCKET`, `ETIMEDOUT`), `errno`, and `syscall`,
// or — for happy-eyeballs / multi-address connect attempts — an
// `AggregateError` whose `errors[]` each carry their own code. Surfacing
// those tells us whether failures are pre-connection (DNS / TCP), mid-stream
// socket resets, or genuine S2 server errors.
function describeFetchError(err: unknown): Record<string, unknown> {
if (!(err instanceof Error)) {
return { error: String(err) };
}
const out: Record<string, unknown> = {
error: err.message,
name: err.name,
};
const cause = (err as { cause?: unknown }).cause;
if (cause && typeof cause === "object") {
const c = cause as Record<string, unknown>;
if (typeof c.code === "string") out.causeCode = c.code;
if (typeof c.errno === "number" || typeof c.errno === "string") out.causeErrno = c.errno;
if (typeof c.syscall === "string") out.causeSyscall = c.syscall;
if (typeof c.message === "string") out.causeMessage = c.message;
if (Array.isArray(c.errors)) {
out.causeErrors = c.errors
.filter((e: unknown): e is Error => e instanceof Error)
.map((e) => ({
message: e.message,
code: (e as { code?: unknown }).code,
syscall: (e as { syscall?: unknown }).syscall,
address: (e as { address?: unknown }).address,
port: (e as { port?: unknown }).port,
}));
}
}
return out;
}
@@ -40,10 +40,22 @@ type EnsureRunForSessionParams = {
/**
* Session row to operate on. Caller is responsible for the env match —
* we don't re-check `runtimeEnvironmentId` against `environment.id`.
*
* `friendlyId` is used to pre-populate `payload.sessionId` on the new
* run so the agent's `chat.agent` boot path can attach to `session.in/.out`
* without a control-plane round-trip. `currentRunId` is also forwarded
* as `payload.previousRunId` (with `continuation: true`) when the prior
* run is dead, so the agent's boot gate triggers snapshot.read + replay
* instead of treating the run as a fresh chat.
*/
session: Pick<
Session,
"id" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
| "id"
| "friendlyId"
| "taskIdentifier"
| "triggerConfig"
| "currentRunId"
| "currentRunVersion"
>;
environment: AuthenticatedEnvironment;
reason: EnsureRunReason;
@@ -97,20 +109,78 @@ export async function ensureRunForSession(
}
// 1. Probe currentRunId.
let priorDeadRunFriendlyId: string | undefined;
if (session.currentRunId) {
const status = await getRunStatus(session.currentRunId);
if (status && !isFinalRunStatus(status)) {
const probe = await getRunStatusAndFriendlyId(session.currentRunId);
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: session.currentRunId, triggered: false };
}
// Either the row vanished (probe null) or its status is final. Either
// way the prior run isn't going to consume new appends — but the
// session may still hold conversation state on `session.out` and an
// S3 snapshot keyed on `session.friendlyId`. Forward the prior run's
// public-form id (friendlyId — same shape as `ctx.run.id`) to the
// agent as `previousRunId` so its boot gate flips
// `couldHavePriorState` and replays the persisted state instead of
// treating this as a fresh chat. See `chat.agent`'s boot orchestration
// in `packages/trigger-sdk/src/v3/ai.ts`.
if (probe?.friendlyId) {
priorDeadRunFriendlyId = probe.friendlyId;
} else {
// Replica miss on a row we just observed via `currentRunId`. Retry
// on the writer so the customer's `runs.retrieve(previousRunId)`
// gets the public `run_*` form rather than the internal cuid.
const writerProbe = await prisma.taskRun.findFirst({
where: { id: session.currentRunId },
select: { friendlyId: true },
});
priorDeadRunFriendlyId = writerProbe?.friendlyId ?? session.currentRunId;
}
}
// 2. Validate config + trigger upfront.
// 2. Validate config + trigger upfront. Continuation overrides
// (`continuation`, `previousRunId`) are derived from session state above
// and merged AFTER caller-supplied overrides — caller can't accidentally
// unset them on a session that has had a prior run, but can still
// override `trigger`/`metadata` etc. `sessionId` is always set so the
// agent doesn't need a control-plane round-trip to look up the session
// friendlyId from `payload.chatId`.
// Continuation overrides strip the basePayload's first-run-only fields
// so a continuation run doesn't inherit a stale boot payload. The Session
// row's `triggerConfig.basePayload` is captured at create-time and used
// verbatim for every Run we trigger; if the customer included `message`
// / `messages` / `trigger: "submit-message"` to make the FIRST run boot
// straight into a first turn (via `chat.createStartSessionAction`), those
// values stick around and get replayed on every continuation. With
// `continuation: true` and `message`/`messages` cleared, the SDK boot
// path enters its continuation-wait branch and waits for the next
// session.in record before running a turn.
const continuationOverrides: Record<string, unknown> = {
sessionId: session.friendlyId,
...(priorDeadRunFriendlyId !== undefined
? {
continuation: true,
previousRunId: priorDeadRunFriendlyId,
// Clear sticky boot-payload fields so the new run waits for the
// next session.in record instead of re-processing whatever was
// in the original `createStartSessionAction({ basePayload })`.
message: undefined,
messages: undefined,
trigger: undefined,
}
: {}),
};
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
...continuationOverrides,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides,
payloadOverrides: mergedPayloadOverrides,
});
// 3. Try to claim the slot atomically.
@@ -161,6 +231,7 @@ export async function ensureRunForSession(
where: { id: session.id },
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
triggerConfig: true,
currentRunId: true,
@@ -175,8 +246,15 @@ export async function ensureRunForSession(
}
if (fresh.currentRunId) {
const status = await getRunStatus(fresh.currentRunId);
if (status && !isFinalRunStatus(status)) {
// Same read-after-write reason as the `fresh` reload above: the winner
// just wrote `currentRunId` on the writer, so probe the writer too —
// the replica may not have the run row yet, and a missed probe forces
// another trigger+recurse until `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS`.
const probe = await prisma.taskRun.findFirst({
where: { id: fresh.currentRunId },
select: { status: true, friendlyId: true },
});
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: fresh.currentRunId, triggered: false };
}
}
@@ -223,6 +301,9 @@ async function triggerSessionRun(params: {
...(config.queue ? { queue: { name: config.queue } } : {}),
...(config.tags ? { tags: config.tags } : {}),
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
...(config.region ? { region: config.region } : {}),
},
};
@@ -242,15 +323,32 @@ async function triggerSessionRun(params: {
}
type SwapSessionRunParams = {
/**
* Session row to swap. `friendlyId` is forwarded as `payload.sessionId`
* on the new run so the agent attaches to `session.in/.out` without a
* control-plane round-trip (same convention as
* {@link EnsureRunForSessionParams}).
*/
session: Pick<
Session,
"id" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
| "id"
| "friendlyId"
| "taskIdentifier"
| "triggerConfig"
| "currentRunId"
| "currentRunVersion"
>;
/**
* The run requesting the swap. Optimistic claim requires
* `Session.currentRunId === callingRunId` so the swap can't clobber
* a run triggered out-of-band (e.g. a parallel `.in/append` probe
* that already replaced the dead run).
*
* Also forwarded as `payload.previousRunId` on the new run alongside
* `continuation: true` — every swap is a continuation by construction
* (`chat.requestUpgrade` / `chat.endRun` deliberately hand off prior
* conversation state to a new run), so the agent's boot gate flips
* `couldHavePriorState` and replays the snapshot + session.out tail.
*/
callingRunId: string;
environment: AuthenticatedEnvironment;
@@ -285,12 +383,39 @@ export async function swapSessionRun(
): Promise<SwapSessionRunResult> {
const { session, callingRunId, environment, reason, payloadOverrides } = params;
// `callingRunId` is the internal cuid (`Session.currentRunId` stores
// cuid; the route handler resolves the wire's friendlyId before passing
// it here). The agent's `previousRunId` is customer-visible and must
// match the public `run_*` form exposed via `ctx.run.id` — resolve
// before forwarding.
const callingRunFriendlyId = await resolveRunFriendlyId(callingRunId);
// Continuation overrides — unconditionally set on swap. Unlike
// `ensureRunForSession`, there's no dead-run-detection branch here:
// every swap is a deliberate handoff from `callingRunId` (which owned
// prior conversation state) to a fresh run. Merged AFTER caller-supplied
// overrides so a caller can't accidentally unset them.
//
// Sticky boot-payload fields (`message` / `messages` / `trigger`) are
// cleared here for the same reason as in `ensureRunForSession`: the
// Session's basePayload is captured at create-time and replays on every
// continuation if not stripped. See the comment in `ensureRunForSession`.
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
sessionId: session.friendlyId,
continuation: true,
previousRunId: callingRunFriendlyId,
message: undefined,
messages: undefined,
trigger: undefined,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides,
payloadOverrides: mergedPayloadOverrides,
});
const claim = await prisma.session.updateMany({
@@ -341,20 +466,55 @@ export async function swapSessionRun(
select: { currentRunId: true },
});
// Mirror `ensureRunForSession`'s "session vanished" branch: if we
// can't find the row (or it has no current run) on the writer right
// after losing the race, surface as an error rather than handing back
// `callingRunId` with `swapped: false` — that would tell the caller
// it's still the canonical run when in fact we don't know who is.
if (!fresh?.currentRunId) {
throw new SessionRunManagerError(
`Session ${session.id} has no currentRunId after preempted swap`
);
}
return {
runId: fresh?.currentRunId ?? callingRunId,
runId: fresh.currentRunId,
swapped: false,
};
}
async function getRunStatus(runId: string): Promise<TaskRunStatus | null> {
async function getRunStatusAndFriendlyId(
runId: string
): Promise<{ status: TaskRunStatus; friendlyId: string } | null> {
// Use the read replica — this is a hot-path probe and stale-by-ms is
// fine. The append handler re-checks if it ends up reusing the runId.
// `friendlyId` is fetched alongside `status` so the dead-run-detection
// branch in `ensureRunForSession` can forward the public-form id as
// `payload.previousRunId` without a second read. `Session.currentRunId`
// stores the internal cuid; the agent's wire / customer hooks expose
// the friendlyId via `ctx.run.id`, so consistency matters.
const row = await $replica.taskRun.findFirst({
where: { id: runId },
select: { status: true },
select: { status: true, friendlyId: true },
});
return row?.status ?? null;
return row ?? null;
}
/**
* Resolve a TaskRun cuid to its friendlyId. Used by `swapSessionRun` to
* forward the calling run's public-form id as `payload.previousRunId` on
* the new run. Falls back to the cuid on lookup miss so the swap doesn't
* fail just because the read replica hasn't caught up — the agent only
* uses `previousRunId` for customer-visible bookkeeping (e.g.
* `runs.retrieve(previousRunId)`), so a stale-but-non-null value is
* acceptable degraded behavior.
*/
async function resolveRunFriendlyId(runId: string): Promise<string> {
const row = await $replica.taskRun.findFirst({
where: { id: runId },
select: { friendlyId: true },
});
return row?.friendlyId ?? runId;
}
async function cancelLostRaceRun(
@@ -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;
};
}>;
@@ -101,6 +101,7 @@ export class ClickHouseSessionsRepository implements ISessionsRepository {
createdAt: true,
updatedAt: true,
runtimeEnvironmentId: true,
currentRunId: true,
},
});
@@ -95,6 +95,7 @@ export type ListedSession = Prisma.SessionGetPayload<{
createdAt: true;
updatedAt: true;
runtimeEnvironmentId: true;
currentRunId: true;
};
}>;
+42
View File
@@ -318,6 +318,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,
@@ -486,6 +511,23 @@ export function v3BatchesPath(
return `${v3EnvironmentPath(organization, project, environment)}/batches`;
}
export function v3SessionsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/sessions`;
}
export function v3SessionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
session: { friendlyId: string }
) {
return `${v3SessionsPath(organization, project, environment)}/${session.friendlyId}`;
}
export function v3BatchPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -324,6 +324,13 @@ async function createWorkerTask(
);
}
const resolvedTriggerSource =
task.triggerSource === "schedule"
? ("SCHEDULED" as const)
: task.triggerSource === "agent"
? ("AGENT" as const)
: ("STANDARD" as const);
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
@@ -337,7 +344,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:
@@ -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;
@@ -84,6 +84,7 @@ describe("Task Runs V2", () => {
null, // max_duration_in_seconds
"", // trigger_source
"", // root_trigger_source
"", // task_kind
null, // is_warm_start
];
@@ -215,6 +216,7 @@ describe("Task Runs V2", () => {
null, // max_duration_in_seconds
"", // trigger_source
"", // root_trigger_source
"", // task_kind
null, // is_warm_start
];
@@ -269,6 +271,7 @@ describe("Task Runs V2", () => {
null, // max_duration_in_seconds
"", // trigger_source
"", // root_trigger_source
"", // task_kind
null, // is_warm_start
];
@@ -370,6 +373,7 @@ describe("Task Runs V2", () => {
null, // max_duration_in_seconds
"", // trigger_source
"", // root_trigger_source
"", // task_kind
null, // is_warm_start
];
@@ -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,
];
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'AGENT';
-- AlterTable
ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN "config" JSONB;
@@ -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;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."PlaygroundConversation" ADD COLUMN "lastEventId" TEXT,
ADD COLUMN "messages" JSONB;
@@ -386,7 +386,8 @@ model RuntimeEnvironment {
waitpointTags WaitpointTag[]
BulkActionGroup BulkActionGroup[]
customerQueries CustomerQuery[]
prompts Prompt[]
prompts Prompt[]
playgroundConversations PlaygroundConversation[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
revokedApiKeys RevokedApiKey[]
@@ -470,6 +471,7 @@ model Project {
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
playgroundConversations PlaygroundConversation[]
buildSettings Json?
onboardingData Json?
@@ -706,6 +708,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])
@@ -718,6 +724,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).
@@ -1021,6 +1070,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
+12
View File
@@ -128,6 +128,18 @@ export class PermissionDeniedError extends ApiError {
override readonly status: 403 = 403;
}
/**
* True when `error` is a 401/403 from the Trigger API (e.g. expired run-scoped PAT on realtime streams).
* Uses structural checks so it works even if multiple copies of `@trigger.dev/core` are bundled (subclass `instanceof` can fail).
*/
export function isTriggerRealtimeAuthError(error: unknown): boolean {
if (error === null || typeof error !== "object") {
return false;
}
const e = error as ApiError;
return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
}
export class NotFoundError extends ApiError {
override readonly status: 404 = 404;
}
+311
View File
@@ -6,19 +6,32 @@ import {
ApiDeploymentListOptions,
ApiDeploymentListResponseItem,
ApiDeploymentListSearchParams,
RetrieveCurrentDeploymentResponseBody,
AppendToStreamResponseBody,
BatchItemNDJSON,
BatchTaskRunExecutionResult,
BatchTriggerTaskV3RequestBody,
BatchTriggerTaskV3Response,
CanceledRunResponse,
CloseSessionRequestBody,
CompleteWaitpointTokenRequestBody,
CompleteWaitpointTokenResponseBody,
CreatedSessionResponseBody,
CreateSessionRequestBody,
EndAndContinueSessionRequestBody,
EndAndContinueSessionResponseBody,
ListSessionsOptions,
ListSessionsResponseBody,
ListedSessionItem,
RetrieveSessionResponseBody,
UpdateSessionRequestBody,
CreateBatchRequestBody,
CreateBatchResponse,
CreateEnvironmentVariableRequestBody,
CreateInputStreamWaitpointRequestBody,
CreateInputStreamWaitpointResponseBody,
CreateSessionStreamWaitpointRequestBody,
CreateSessionStreamWaitpointResponseBody,
CreateScheduleOptions,
CreateStreamResponseBody,
CreateUploadPayloadUrlResponseBody,
@@ -59,6 +72,7 @@ import {
SendInputStreamResponseBody,
StreamBatchItemsResponse,
TaskRunExecutionResult,
ReadSessionStreamRecordsResponseBody,
TriggerTaskRequestBody,
TriggerTaskResponse,
UpdateEnvironmentVariableRequestBody,
@@ -1094,6 +1108,233 @@ export class ApiClient {
);
}
// ========================================================================
// Sessions
// ========================================================================
createSession(body: CreateSessionRequestBody, requestOptions?: ZodFetchOptions) {
return zodfetch(
CreatedSessionResponseBody,
`${this.baseUrl}/api/v1/sessions`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
retrieveSession(sessionIdOrExternalId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveSessionResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
updateSession(
sessionIdOrExternalId: string,
body: UpdateSessionRequestBody,
requestOptions?: ZodFetchOptions
) {
return zodfetch(
RetrieveSessionResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`,
{
method: "PATCH",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
closeSession(
sessionIdOrExternalId: string,
body?: CloseSessionRequestBody,
requestOptions?: ZodFetchOptions
) {
return zodfetch(
RetrieveSessionResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/close`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body ?? {}),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
endAndContinueSession(
sessionIdOrExternalId: string,
body: EndAndContinueSessionRequestBody,
requestOptions?: ZodFetchOptions
) {
return zodfetch(
EndAndContinueSessionResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/end-and-continue`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
listSessions(
options?: ListSessionsOptions,
requestOptions?: ZodFetchOptions
): CursorPagePromise<typeof ListedSessionItem> {
const searchParams = createSearchQueryForListSessions(options);
return zodfetchCursorPage(
ListedSessionItem,
`${this.baseUrl}/api/v1/sessions`,
{
query: searchParams,
limit: options?.limit,
after: options?.after,
before: options?.before,
},
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
// ========================================================================
// Session realtime channels
// ========================================================================
async initializeSessionStream(
sessionIdOrExternalId: string,
io: "out" | "in",
requestOptions?: ZodFetchOptions
) {
// The server returns S2 credentials in response headers alongside a tiny
// JSON body with the realtime version. Follow the same shape as
// `createStream` so downstream clients can feed them into
// `StreamsWriterV2`.
return zodfetch(
CreateStreamResponseBody,
`${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`,
{
method: "PUT",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
)
.withResponse()
.then(({ data, response }) => ({
...data,
headers: Object.fromEntries(response.headers.entries()),
}));
}
async appendToSessionStream<TBody extends BodyInit>(
sessionIdOrExternalId: string,
io: "out" | "in",
part: TBody,
requestOptions?: ZodFetchOptions
) {
return zodfetch(
AppendToStreamResponseBody,
`${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}/append`,
{
method: "POST",
headers: this.#getHeaders(false),
body: part,
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
/**
* Non-SSE drain of a Session channel's tail. Returns whatever records
* exist after `afterEventId` (or from the head of the stream) and closes
* `wait=0` semantics, no long-poll. Used by `replaySessionOutTail` at
* run boot, where the SSE long-poll's ~1s tax on empty streams is the
* dominant cost on every fresh chat.
*
* `afterEventId` is the same cursor format as the SSE Last-Event-ID
* (the S2 sequence number, stringified) pass `lastOutEventId` from a
* persisted snapshot to resume.
*/
async readSessionStreamRecords(
sessionIdOrExternalId: string,
io: "out" | "in",
options?: { afterEventId?: string; baseUrl?: string }
) {
const qs = new URLSearchParams();
if (options?.afterEventId !== undefined) {
qs.set("afterEventId", options.afterEventId);
}
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(
sessionIdOrExternalId
)}/${io}/records${qs.toString() ? `?${qs.toString()}` : ""}`;
return zodfetch(
ReadSessionStreamRecordsResponseBody,
url,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, undefined)
);
}
/**
* Subscribe to SSE records on a Session channel. Reuses the same
* {@link SSEStreamSubscription} plumbing as `readStream` for run-scoped
* realtime streams auto-retry, Last-Event-ID resume, abort-on-cancel.
*/
async subscribeToSessionStream<T = unknown>(
sessionIdOrExternalId: string,
io: "out" | "in",
options?: {
signal?: AbortSignal;
baseUrl?: string;
timeoutInSeconds?: number;
onComplete?: () => void;
onError?: (error: Error) => void;
lastEventId?: string;
onPart?: (part: SSEStreamPart<T>) => void;
}
): Promise<AsyncIterableStream<T>> {
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`;
const subscription = new SSEStreamSubscription(url, {
headers: this.getHeaders(),
signal: options?.signal,
onComplete: options?.onComplete,
onError: options?.onError,
timeoutInSeconds: options?.timeoutInSeconds,
lastEventId: options?.lastEventId,
});
const stream = await subscription.subscribe();
const onPart = options?.onPart;
return stream.pipeThrough(
new TransformStream<SSEStreamPart, T>({
transform(chunk, controller) {
const data = chunk.chunk as T;
onPart?.(chunk as SSEStreamPart<T>);
controller.enqueue(data);
},
})
);
}
async waitForDuration(
runId: string,
body: WaitForDurationRequestBody,
@@ -1340,6 +1581,18 @@ export class ApiClient {
);
}
retrieveCurrentDeployment(requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveCurrentDeploymentResponseBody,
`${this.baseUrl}/api/v1/deployments/current`,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
async fetchStream<T>(
runId: string,
streamKey: string,
@@ -1459,6 +1712,23 @@ export class ApiClient {
);
}
async createSessionStreamWaitpoint(
runFriendlyId: string,
body: CreateSessionStreamWaitpointRequestBody,
requestOptions?: ZodFetchOptions
) {
return zodfetch(
CreateSessionStreamWaitpointResponseBody,
`${this.baseUrl}/api/v1/runs/${runFriendlyId}/session-streams/wait`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
async generateJWTClaims(requestOptions?: ZodFetchOptions): Promise<Record<string, any>> {
return zodfetch(
z.record(z.any()),
@@ -1823,6 +2093,47 @@ function queueNameFromQueueTypeName(queue: QueueTypeName): string {
return queue.name;
}
function createSearchQueryForListSessions(options?: ListSessionsOptions): URLSearchParams {
const searchParams = new URLSearchParams();
if (!options) return searchParams;
const appendMany = (name: string, value: string | string[] | undefined) => {
if (value === undefined) return;
searchParams.append(name, Array.isArray(value) ? value.join(",") : value);
};
appendMany("filter[type]", options.type);
appendMany("filter[tags]", options.tag);
appendMany("filter[taskIdentifier]", options.taskIdentifier);
if (options.externalId) {
searchParams.append("filter[externalId]", options.externalId);
}
appendMany("filter[status]", options.status as string | string[] | undefined);
if (options.period) {
searchParams.append("filter[createdAt][period]", options.period);
}
if (options.from !== undefined) {
searchParams.append(
"filter[createdAt][from]",
options.from instanceof Date ? options.from.getTime().toString() : options.from.toString()
);
}
if (options.to !== undefined) {
searchParams.append(
"filter[createdAt][to]",
options.to instanceof Date ? options.to.getTime().toString() : options.to.toString()
);
}
return searchParams;
}
function createSearchQueryForListWaitpointTokens(
query?: ListWaitpointTokensQueryParams
): URLSearchParams {
@@ -0,0 +1,444 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SSEStreamSubscription } from "./runStream.js";
vi.setConfig({ testTimeout: 10_000 });
describe("SSEStreamSubscription retry behavior", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
// A response.body that emits one SSE event then closes, so each
// successful subscribe() exits cleanly via reader.read() done=true
// and the test doesn't hang reading from a long-lived stream.
function makeSSEResponse() {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`id: 1\ndata: {"hello":1}\n\n`));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
});
}
// Drain a ReadableStream<SSEStreamPart> until it closes or errors.
// Returns received chunks plus terminal state.
async function drain(stream: ReadableStream<{ id: string; chunk: unknown }>) {
const reader = stream.getReader();
const chunks: Array<{ id: string; chunk: unknown }> = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) return { chunks, error: undefined as Error | undefined };
chunks.push(value);
}
} catch (e) {
return { chunks, error: e as Error };
} finally {
try {
reader.releaseLock();
} catch {
/* already released */
}
}
}
it("retries past the legacy 5-attempt cap", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
if (attempts < 8) {
throw new TypeError("fetch failed (simulated network drop)");
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
// Compress the timing for the test — defaults are 100ms initial,
// 5s cap, retry forever; here we want fast iteration.
retryDelayMs: 1,
maxRetryDelayMs: 5,
});
const stream = await sub.subscribe();
const result = await drain(stream);
expect(attempts).toBe(8);
expect(result.error).toBeUndefined();
expect(result.chunks).toHaveLength(1);
});
it("caps the exponential backoff at maxRetryDelayMs", async () => {
let attempts = 0;
const callTimes: number[] = [];
globalThis.fetch = vi.fn().mockImplementation(async () => {
callTimes.push(Date.now());
attempts++;
if (attempts < 6) {
throw new TypeError("fetch failed");
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 10,
maxRetryDelayMs: 30,
});
const stream = await sub.subscribe();
await drain(stream);
expect(attempts).toBe(6);
// Without the cap, backoff would be 10, 20, 40, 80, 160 (= 310ms total).
// With cap=30, it's 10, 20, 30, 30, 30 (= 120ms total). Allow generous
// slack for setTimeout jitter; the assertion is "well under uncapped".
const totalElapsed = callTimes.at(-1)! - callTimes[0]!;
expect(totalElapsed).toBeLessThan(250);
});
it("retryNow() wakes an in-flight backoff and reconnects immediately", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
if (attempts === 1) {
throw new TypeError("fetch failed");
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
// Backoff is intentionally long. retryNow() should short-circuit it.
retryDelayMs: 5_000,
maxRetryDelayMs: 5_000,
});
const subscribePromise = sub.subscribe().then(drain);
// Wait for the first attempt to fail and the backoff to start.
await new Promise((r) => setTimeout(r, 50));
sub.retryNow();
const start = Date.now();
const result = await subscribePromise;
const elapsed = Date.now() - start;
expect(attempts).toBe(2);
expect(result.error).toBeUndefined();
// Without retryNow this would have waited ~5000ms; with it, the
// second attempt fires nearly immediately after the first failure.
expect(elapsed).toBeLessThan(500);
});
it("respects abort signal during retry backoff", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
throw new TypeError("fetch failed");
});
const ac = new AbortController();
const sub = new SSEStreamSubscription("http://example.test/sse", {
signal: ac.signal,
retryDelayMs: 1_000,
maxRetryDelayMs: 1_000,
});
const subscribePromise = sub.subscribe().then(drain);
// Let the first attempt fail and enter backoff, then abort.
await new Promise((r) => setTimeout(r, 50));
ac.abort();
const result = await subscribePromise;
expect(result.error).toBeUndefined();
// Abort should stop retries; we should have made at most a couple
// of attempts before the abort took effect.
expect(attempts).toBeLessThanOrEqual(2);
});
it("forceReconnect mid-read drops the stream and resumes with Last-Event-ID", async () => {
let attempts = 0;
const seenLastEventIds: Array<string | null> = [];
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
attempts++;
const lastEventIdHeader = (init?.headers as Record<string, string> | undefined)?.[
"Last-Event-ID"
];
seenLastEventIds.push(lastEventIdHeader ?? null);
if (attempts === 1) {
// Headers arrive immediately, body emits one chunk then hangs
// until aborted. The test calls forceReconnect after seeing
// the chunk, which should drop this stream and trigger a
// resume request with Last-Event-ID set.
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`id: 7\ndata: {"first":true}\n\n`));
init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
});
}
// Second attempt: emit a second chunk and close cleanly.
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`id: 8\ndata: {"second":true}\n\n`));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
});
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
fetchTimeoutMs: 60_000,
});
const stream = await sub.subscribe();
const reader = stream.getReader();
// Read the first chunk, then force-reconnect mid-stream.
const first = await reader.read();
expect(first.done).toBe(false);
expect((first.value!.chunk as { first?: boolean }).first).toBe(true);
sub.forceReconnect();
// Second chunk arrives from the resumed connection.
const second = await reader.read();
expect(second.done).toBe(false);
expect((second.value!.chunk as { second?: boolean }).second).toBe(true);
const tail = await reader.read();
expect(tail.done).toBe(true);
expect(attempts).toBe(2);
expect(seenLastEventIds[0]).toBeNull();
// Resumed request includes the Last-Event-ID from the first chunk.
expect(seenLastEventIds[1]).toBe("7");
});
it("forceReconnect aborts the in-flight fetch and retries", async () => {
let attempts = 0;
let firstResolve: (() => void) | undefined;
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
attempts++;
if (attempts === 1) {
// Hang the first attempt forever (or until signal aborts).
// forceReconnect should make this attempt's signal abort and
// throw, taking us into the retry path.
return new Promise((resolve, reject) => {
firstResolve = () => resolve(makeSSEResponse());
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
});
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
// Long fetch timeout so it doesn't fire instead of forceReconnect.
fetchTimeoutMs: 60_000,
});
const subscribePromise = sub.subscribe().then(drain);
// Let the first fetch hang, then force reconnect.
await new Promise((r) => setTimeout(r, 50));
sub.forceReconnect();
const result = await subscribePromise;
expect(attempts).toBe(2);
expect(result.error).toBeUndefined();
expect(result.chunks).toHaveLength(1);
// Sanity: the hung first fetch was abandoned, never resolved.
expect(firstResolve).toBeDefined();
});
it("aborts a slow fetch via fetchTimeoutMs and retries", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
attempts++;
if (attempts === 1) {
// Hang until aborted.
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("aborted", "AbortError"));
});
});
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
fetchTimeoutMs: 100,
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(2);
expect(result.error).toBeUndefined();
expect(result.chunks).toHaveLength(1);
});
it("aborts a silent reader via stallTimeoutMs and retries", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
attempts++;
if (attempts === 1) {
// Headers arrive immediately, but the body stream emits no
// chunks until aborted. The stall timer should fire and
// force a reconnect.
const body = new ReadableStream<Uint8Array>({
start(controller) {
init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
});
}
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
stallTimeoutMs: 100,
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(2);
expect(result.error).toBeUndefined();
expect(result.chunks).toHaveLength(1);
});
it("does not retry on 404 (stream gone)", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
return new Response("not found", { status: 404 });
});
const errors: Error[] = [];
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
onError: (e) => errors.push(e),
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(1);
expect(result.error).toBeDefined();
expect(errors).toHaveLength(1);
});
it("does not retry on 410 (session closed)", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
return new Response("gone", { status: 410 });
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(1);
expect(result.error).toBeDefined();
});
it("respects custom nonRetryableStatuses", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
return new Response("forbidden", { status: 403 });
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
nonRetryableStatuses: [403],
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(1);
expect(result.error).toBeDefined();
});
it("retries on 503 (caller-tunable nonRetryableStatuses)", async () => {
let attempts = 0;
globalThis.fetch = vi.fn().mockImplementation(async () => {
attempts++;
if (attempts < 3) return new Response("unavailable", { status: 503 });
return makeSSEResponse();
});
const sub = new SSEStreamSubscription("http://example.test/sse", {
retryDelayMs: 1,
maxRetryDelayMs: 5,
// 503 is NOT in the default non-retryable set; it should retry.
});
const result = await sub.subscribe().then(drain);
expect(attempts).toBe(3);
expect(result.error).toBeUndefined();
expect(result.chunks).toHaveLength(1);
});
it("applies jitter to backoff (delays vary across attempts)", async () => {
const callTimes: number[] = [];
globalThis.fetch = vi.fn().mockImplementation(async () => {
callTimes.push(performance.now());
throw new TypeError("fetch failed");
});
const ac = new AbortController();
const sub = new SSEStreamSubscription("http://example.test/sse", {
signal: ac.signal,
retryDelayMs: 50,
maxRetryDelayMs: 50,
retryJitter: 0.5, // 50% — final delay in [25ms, 50ms]
});
const promise = sub.subscribe().then(drain);
await new Promise((r) => setTimeout(r, 600)); // allow ~10 attempts
ac.abort();
await promise;
expect(callTimes.length).toBeGreaterThanOrEqual(5);
// Compute inter-attempt gaps (skip the first since it has no prior).
const gaps = callTimes.slice(1).map((t, i) => t - callTimes[i]!);
// Without jitter all gaps would be ~50ms. With 50% jitter they
// should land in [~25ms, ~50ms] and not all be identical.
const min = Math.min(...gaps);
const max = Math.max(...gaps);
expect(min).toBeGreaterThanOrEqual(20); // a little slack for timer scheduling
expect(max).toBeLessThanOrEqual(80);
// Variance check — at least one gap should differ from another by
// a measurable amount (rules out a deterministic-delay regression).
expect(max - min).toBeGreaterThan(2);
});
});
+166 -31
View File
@@ -14,7 +14,7 @@ import {
IOPacket,
parsePacket,
} from "../utils/ioSerialization.js";
import { ApiError } from "./errors.js";
import { ApiError, isTriggerRealtimeAuthError } from "./errors.js";
import { ApiClient } from "./index.js";
import { zodShapeStream } from "./stream.js";
@@ -182,8 +182,15 @@ export type SSEStreamPart<TChunk = unknown> = {
export class SSEStreamSubscription implements StreamSubscription {
private lastEventId: string | undefined;
private retryCount = 0;
private maxRetries = 5;
private retryDelayMs = 1000;
private maxRetries: number;
private retryDelayMs: number;
private maxRetryDelayMs: number;
private retryJitter: number;
private fetchTimeoutMs: number;
private stallTimeoutMs: number;
private nonRetryableStatuses: ReadonlySet<number>;
private retryNowController: AbortController | null = null;
private internalAbort: AbortController | null = null;
constructor(
private url: string,
@@ -194,9 +201,69 @@ export class SSEStreamSubscription implements StreamSubscription {
onError?: (error: Error) => void;
timeoutInSeconds?: number;
lastEventId?: string;
// Retry knobs. Defaults: retry forever, 100ms initial backoff,
// capped at 5s with 50% jitter. Keeps mobile clients reconnecting
// through transient drops without giving up after a fixed window
// and prevents thundering-herd when many clients reconnect after
// a brief server blip.
maxRetries?: number;
retryDelayMs?: number;
maxRetryDelayMs?: number;
retryJitter?: number;
// Per-attempt fetch timeout — aborts the connect attempt if
// response headers don't arrive in time. Catches stuck TCP
// sockets where `fetch()` blocks forever waiting on a dead
// server. Cleared once headers arrive; long-lived chunk reads
// are governed by `stallTimeoutMs` instead.
fetchTimeoutMs?: number;
// Stall detector — if no chunks arrive within this window after
// the connection is established, force a reconnect. Catches
// silent-dead-socket cases (mobile OS killed the TCP socket but
// the read just blocks). Disabled (`0`) by default; opt in
// explicitly. Servers that emit periodic keepalive comments
// reset the timer naturally.
stallTimeoutMs?: number;
// HTTP statuses that should NOT be retried — fail the stream
// permanently. `404` (stream gone) and `410` (session closed)
// are sensible defaults; tune per-caller for other 4xx.
nonRetryableStatuses?: readonly number[];
}
) {
this.lastEventId = options.lastEventId;
this.maxRetries = options.maxRetries ?? Infinity;
this.retryDelayMs = options.retryDelayMs ?? 100;
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 5000;
this.retryJitter = options.retryJitter ?? 0.5;
this.fetchTimeoutMs = options.fetchTimeoutMs ?? 30_000;
this.stallTimeoutMs = options.stallTimeoutMs ?? 0;
this.nonRetryableStatuses = new Set(options.nonRetryableStatuses ?? [404, 410]);
}
/**
* Wake an in-flight retry backoff and reconnect immediately.
*
* No-op if no retry is currently waiting (i.e. we're already
* connected and reading). Use this for cheap "hint" wakeups like
* the `online` event or a short-hidden visibility return
* `forceReconnect()` is the heavier hammer.
*/
retryNow(): void {
this.retryNowController?.abort();
}
/**
* Drop the current connection (or wake a pending backoff) and
* reconnect.
*
* Use when the existing TCP socket is suspected dead but the reader
* hasn't noticed yet common after a mobile tab background-kill or
* a Safari bfcache restore. Aborts the in-flight fetch / read so
* the catch path takes us through `retryConnection` and re-fetches
* with `Last-Event-ID`.
*/
forceReconnect(): void {
this.internalAbort?.abort();
this.retryNowController?.abort();
}
async subscribe(): Promise<ReadableStream<SSEStreamPart>> {
@@ -206,7 +273,7 @@ export class SSEStreamSubscription implements StreamSubscription {
async start(controller) {
await self.connectStream(controller);
},
cancel(reason) {
cancel() {
self.options.onComplete?.();
},
});
@@ -215,25 +282,51 @@ export class SSEStreamSubscription implements StreamSubscription {
private async connectStream(
controller: ReadableStreamDefaultController<SSEStreamPart>
): Promise<void> {
// Two abort sources flow through `internalAbort.signal`:
// - this.options.signal: caller cancel — bypass retry, exit cleanly.
// - this.internalAbort: per-attempt force-reconnect / fetch-timeout
// / stall-timeout — treated as a transient error, retry path runs.
// Use `this.options.signal?.aborted` in the catch to distinguish.
this.internalAbort = new AbortController();
const unlinkUserAbort = linkAbort(this.options.signal, this.internalAbort);
// Per-attempt fetch timeout. Cleared once response headers arrive;
// chunk-read latency is governed by `stallTimeoutMs` instead.
const fetchTimer = setTimeout(() => this.internalAbort?.abort(), this.fetchTimeoutMs);
let stallTimer: ReturnType<typeof setTimeout> | undefined;
const armStall = () => {
if (this.stallTimeoutMs <= 0) return;
clearTimeout(stallTimer);
stallTimer = setTimeout(() => this.internalAbort?.abort(), this.stallTimeoutMs);
};
// Idempotent — both the catch (before recursion) and the finally
// call this. Without the catch-side call, every retry leaks an
// abort listener on `this.options.signal` because the finally
// doesn't run until the entire recursion unwinds.
const cleanupAttempt = () => {
clearTimeout(fetchTimer);
clearTimeout(stallTimer);
unlinkUserAbort();
this.internalAbort = null;
};
try {
const headers: Record<string, string> = {
Accept: "text/event-stream",
...this.options.headers,
};
// Include Last-Event-ID header if we're resuming
if (this.lastEventId) {
headers["Last-Event-ID"] = this.lastEventId;
}
if (this.lastEventId) headers["Last-Event-ID"] = this.lastEventId;
if (this.options.timeoutInSeconds) {
headers["Timeout-Seconds"] = this.options.timeoutInSeconds.toString();
}
const response = await fetch(this.url, {
headers,
signal: this.options.signal,
signal: this.internalAbort.signal,
});
clearTimeout(fetchTimer);
if (!response.ok) {
const error = ApiError.generate(
@@ -242,22 +335,23 @@ export class SSEStreamSubscription implements StreamSubscription {
"Could not subscribe to stream",
Object.fromEntries(response.headers)
);
this.options.onError?.(error);
if (this.nonRetryableStatuses.has(response.status)) {
controller.error(error);
return;
}
throw error;
}
if (!response.body) {
const error = new Error("No response body");
this.options.onError?.(error);
throw error;
}
const streamVersion = response.headers.get("X-Stream-Version") ?? "v1";
// Reset retry count on successful connection
this.retryCount = 0;
this.retryCount = 0; // reset on success
armStall();
const seenIds = new Set<string>();
@@ -268,13 +362,10 @@ export class SSEStreamSubscription implements StreamSubscription {
new TransformStream<EventSourceMessage, SSEStreamPart>({
transform: (chunk, chunkController) => {
if (streamVersion === "v1") {
// Track the last event ID for resume support
if (chunk.id) {
this.lastEventId = chunk.id;
}
const timestamp = parseRedisStreamIdTimestamp(chunk.id);
chunkController.enqueue({
id: chunk.id ?? "unknown",
chunk: safeParseJSON(chunk.data),
@@ -288,13 +379,9 @@ export class SSEStreamSubscription implements StreamSubscription {
for (const record of data.records) {
this.lastEventId = record.seq_num.toString();
const parsedBody = safeParseJSON(record.body) as { data: unknown; id: string };
if (seenIds.has(parsedBody.id)) {
continue;
}
if (seenIds.has(parsedBody.id)) continue;
seenIds.add(parsedBody.id);
chunkController.enqueue({
id: record.seq_num.toString(),
chunk: parsedBody.data,
@@ -310,7 +397,6 @@ export class SSEStreamSubscription implements StreamSubscription {
const reader = stream.getReader();
try {
let chunkCount = 0;
while (true) {
const { done, value } = await reader.read();
@@ -329,7 +415,7 @@ export class SSEStreamSubscription implements StreamSubscription {
return;
}
chunkCount++;
armStall(); // any chunk (including server keepalives) resets the silence timer
controller.enqueue(value);
}
} catch (error) {
@@ -338,14 +424,24 @@ export class SSEStreamSubscription implements StreamSubscription {
}
} catch (error) {
if (this.options.signal?.aborted) {
// Don't retry if aborted
// User cancel — exit cleanly, don't retry.
controller.close();
this.options.onComplete?.();
return;
}
// Retry on error
if (isTriggerRealtimeAuthError(error)) {
// `onError` was already invoked in the `!response.ok` branch above
// (where the auth ApiError was originally constructed and thrown).
// Auth errors are non-retryable: terminate the stream cleanly.
controller.error(error as Error);
return;
}
cleanupAttempt();
await this.retryConnection(controller, error as Error);
} finally {
cleanupAttempt();
}
}
@@ -367,10 +463,33 @@ export class SSEStreamSubscription implements StreamSubscription {
}
this.retryCount++;
const delay = this.retryDelayMs * Math.pow(2, this.retryCount - 1);
const baseDelay = Math.min(
this.retryDelayMs * Math.pow(2, this.retryCount - 1),
this.maxRetryDelayMs
);
// Jitter scales the delay into [(1 - retryJitter) * base, base].
// E.g. retryJitter=0.5 → final delay is in [50%, 100%] of base.
// Spreads simultaneous reconnect attempts so many clients don't
// dogpile on the server right after a brief outage.
const delay = baseDelay * (1 - this.retryJitter * Math.random());
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, delay));
// Wait before retrying. The wait is wakeable: `retryNow()` aborts
// `retryNowController` so the timer resolves immediately and the
// next connect attempt starts now (e.g. on tab focus / `online`
// event from the browser layer).
this.retryNowController = new AbortController();
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.retryNowController?.signal.removeEventListener("abort", onAbort);
resolve();
}, delay);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
this.retryNowController!.signal.addEventListener("abort", onAbort, { once: true });
});
this.retryNowController = null;
if (this.options.signal?.aborted) {
controller.close();
@@ -383,6 +502,22 @@ export class SSEStreamSubscription implements StreamSubscription {
}
}
/**
* One-way abort link: when `parent` aborts, abort `child` too. Returns
* a cleanup that removes the listener so `parent` doesn't accumulate
* subscriptions across many connect attempts.
*/
function linkAbort(parent: AbortSignal | undefined, child: AbortController): () => void {
if (!parent) return () => {};
if (parent.aborted) {
child.abort();
return () => {};
}
const onAbort = () => child.abort();
parent.addEventListener("abort", onAbort, { once: true });
return () => parent.removeEventListener("abort", onAbort);
}
export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
constructor(
private baseUrl: string,
+20
View File
@@ -631,6 +631,26 @@ export class GracefulExitTimeoutError extends Error {
}
}
export class ChatChunkTooLargeError extends Error {
constructor(
public readonly chunkSize: number,
public readonly maxSize: number,
public readonly chunkType?: string
) {
super(
`chat.agent chunk${chunkType ? ` of type "${chunkType}"` : ""} is ${chunkSize} bytes, ` +
`over the realtime stream's per-record cap of ${maxSize} bytes. ` +
`For oversized payloads (e.g. large tool outputs), write the value to your own store and ` +
`emit only an id/url through the chat stream — see https://trigger.dev/docs/ai-chat/patterns/large-payloads.`
);
this.name = "ChatChunkTooLargeError";
}
}
export function isChatChunkTooLargeError(error: unknown): error is ChatChunkTooLargeError {
return error instanceof Error && error.name === "ChatChunkTooLargeError";
}
export class MaxDurationExceededError extends Error {
constructor(
public readonly maxDurationInSeconds: number,
+2
View File
@@ -21,6 +21,7 @@ export * from "./locals-api.js";
export * from "./heartbeats-api.js";
export * from "./realtime-streams-api.js";
export * from "./input-streams-api.js";
export * from "./session-streams-api.js";
export * from "./waitpoints/index.js";
export * from "./schemas/index.js";
export { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
@@ -80,6 +81,7 @@ export {
getSchemaParseFn,
type AnySchemaParseFn,
type SchemaParseFn,
type inferSchemaOut,
isSchemaZodEsque,
isSchemaValibotEsque,
isSchemaArkTypeEsque,
@@ -51,6 +51,18 @@ export class InputStreamsAPI implements InputStreamManager {
return this.#getManager().lastSeqNum(streamId);
}
public setLastSeqNum(streamId: string, seqNum: number): void {
this.#getManager().setLastSeqNum(streamId, seqNum);
}
public shiftBuffer(streamId: string): boolean {
return this.#getManager().shiftBuffer(streamId);
}
public disconnectStream(streamId: string): void {
this.#getManager().disconnectStream(streamId);
}
public clearHandlers(): void {
this.#getManager().clearHandlers();
}
+134 -13
View File
@@ -6,6 +6,7 @@ import {
InputStreamTimeoutError,
} from "./types.js";
import { InputStreamOnceOptions } from "../realtimeStreams/types.js";
import { computeReconnectDelayMs } from "../utils/reconnectBackoff.js";
type InputStreamHandler = (data: unknown) => void | Promise<void>;
@@ -13,6 +14,13 @@ type OnceWaiter = {
resolve: (result: InputStreamOnceResult<unknown>) => void;
reject: (error: Error) => void;
timeoutHandle?: ReturnType<typeof setTimeout>;
// The abort signal and its handler are tracked on the waiter so any
// resolution path (dispatch / timeout / explicit removal) can detach
// the listener. Without this, a long-lived `AbortSignal` reused across
// many `once()` calls accumulates listeners — `{ once: true }` only
// self-clears if the signal actually aborts.
signal?: AbortSignal;
abortHandler?: () => void;
};
@@ -29,6 +37,19 @@ export class StandardInputStreamManager implements InputStreamManager {
private seqNums = new Map<string, number>();
private currentRunId: string | null = null;
private streamsVersion: string | undefined;
// Reconnect attempt counter per streamId. Drives the exponential
// backoff applied by `#ensureStreamTailConnected`'s `.finally` so a
// persistent backend failure (auth rejection, 5xx, DNS, etc.) doesn't
// reconnect in a tight loop. Reset to 0 by `#dispatch` whenever a
// record flows through.
private reconnectAttempts = new Map<string, number>();
// Stream IDs that were explicitly torn down by `disconnectStream`. The
// tail's `.finally` reconnect path consults this set so a deliberate
// teardown isn't immediately undone by the auto-reconnect when
// handlers or once-waiters are still registered. Cleared on the next
// explicit `on()` / `once()` (those are the only legitimate reasons to
// bring the tail back up).
private explicitlyDisconnected = new Set<string>();
constructor(
private apiClient: ApiClient,
@@ -40,6 +61,26 @@ export class StandardInputStreamManager implements InputStreamManager {
return this.seqNums.get(streamId);
}
setLastSeqNum(streamId: string, seqNum: number): void {
const current = this.seqNums.get(streamId);
// Only advance forward, never backward
if (current === undefined || seqNum > current) {
this.seqNums.set(streamId, seqNum);
}
}
shiftBuffer(streamId: string): boolean {
const buffered = this.buffer.get(streamId);
if (buffered && buffered.length > 0) {
buffered.shift();
if (buffered.length === 0) {
this.buffer.delete(streamId);
}
return true;
}
return false;
}
setRunId(runId: string, streamsVersion?: string): void {
this.currentRunId = runId;
this.streamsVersion = streamsVersion;
@@ -48,6 +89,10 @@ export class StandardInputStreamManager implements InputStreamManager {
on(streamId: string, handler: InputStreamHandler): { off: () => void } {
this.#requireV2Streams();
// A fresh attach is a legitimate reason to bring the tail back up;
// clear any prior explicit-disconnect flag.
this.explicitlyDisconnected.delete(streamId);
let handlerSet = this.handlers.get(streamId);
if (!handlerSet) {
handlerSet = new Set();
@@ -80,6 +125,10 @@ export class StandardInputStreamManager implements InputStreamManager {
once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise<unknown> {
this.#requireV2Streams();
// A fresh waiter is a legitimate reason to bring the tail back up;
// clear any prior explicit-disconnect flag.
this.explicitlyDisconnected.delete(streamId);
// Lazily connect a tail for this stream
this.#ensureStreamTailConnected(streamId);
@@ -104,17 +153,16 @@ export class StandardInputStreamManager implements InputStreamManager {
reject(new Error("Aborted"));
return;
}
options.signal.addEventListener(
"abort",
() => {
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
this.#removeOnceWaiter(streamId, waiter);
reject(new Error("Aborted"));
},
{ once: true }
);
const abortHandler = () => {
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
this.#removeOnceWaiter(streamId, waiter);
reject(new Error("Aborted"));
};
waiter.signal = options.signal;
waiter.abortHandler = abortHandler;
options.signal.addEventListener("abort", abortHandler, { once: true });
}
// Handle timeout — resolve with error result instead of rejecting
@@ -158,10 +206,40 @@ export class StandardInputStreamManager implements InputStreamManager {
}
}
disconnectStream(streamId: string): void {
// Mark as explicitly disconnected BEFORE we abort, so the tail's
// `.finally` reconnect path sees the flag when it runs (which can be
// synchronous in the AbortError catch). Without this, an in-flight
// `.on(...)` or pending `.once()` would immediately resurrect the
// tail and negate the disconnect — defeating the
// "drop-the-duplicate before .wait() suspends" contract. Cleared on
// the next explicit `on()` / `once()`.
this.explicitlyDisconnected.add(streamId);
const tail = this.tails.get(streamId);
if (tail) {
tail.abortController.abort();
this.tails.delete(streamId);
}
this.buffer.delete(streamId);
// Reset the backoff counter so a future re-attach starts fresh —
// an explicit disconnect is a deliberate teardown, not evidence of
// a broken backend.
this.reconnectAttempts.delete(streamId);
}
connectTail(runId: string, _fromSeq?: number): void {
// No-op: tails are now created per-stream lazily
}
/**
* Tear down all active tails. Does NOT clear handlers or `onceWaiters`,
* so any registered listener will trigger an auto-reconnect (with
* backoff) the moment it sees no live tail by design, so a transient
* network blip recovers without the caller re-subscribing. Use
* `reset()` if you want a full clean state with no resurrection, or
* `disconnectStream(streamId)` for a single stream that should stay
* down until a fresh `on()` / `once()` attaches.
*/
disconnect(): void {
for (const [, tail] of this.tails) {
tail.abortController.abort();
@@ -175,6 +253,8 @@ export class StandardInputStreamManager implements InputStreamManager {
this.streamsVersion = undefined;
this.seqNums.clear();
this.handlers.clear();
this.reconnectAttempts.clear();
this.explicitlyDisconnected.clear();
// Reject all pending once waiters
for (const [, waiters] of this.onceWaiters) {
@@ -182,6 +262,9 @@ export class StandardInputStreamManager implements InputStreamManager {
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
waiter.reject(new Error("Input stream manager reset"));
}
}
@@ -209,13 +292,37 @@ export class StandardInputStreamManager implements InputStreamManager {
.finally(() => {
this.tails.delete(streamId);
// Auto-reconnect if there are still active handlers or waiters
// If the tail was torn down explicitly via `disconnectStream`,
// don't auto-reconnect — that's the whole point of the
// disconnect call. The next `on()` / `once()` clears the flag.
if (this.explicitlyDisconnected.has(streamId)) {
return;
}
// Auto-reconnect with exponential backoff if there are still
// active handlers or waiters. Without backoff a persistent
// failure (auth rejected, 5xx, DNS) would reconnect in a tight
// loop because `#runTail`'s error path only logs. `#dispatch`
// resets the counter on every successful record.
const hasHandlers =
this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0;
const hasWaiters =
this.onceWaiters.has(streamId) && this.onceWaiters.get(streamId)!.length > 0;
if (hasHandlers || hasWaiters) {
this.#ensureStreamTailConnected(streamId);
const attempt = this.reconnectAttempts.get(streamId) ?? 0;
this.reconnectAttempts.set(streamId, attempt + 1);
const delayMs = computeReconnectDelayMs(attempt);
setTimeout(() => {
if (this.explicitlyDisconnected.has(streamId)) return;
if (this.tails.has(streamId)) return;
const stillHasHandlers =
this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0;
const stillHasWaiters =
this.onceWaiters.has(streamId) &&
this.onceWaiters.get(streamId)!.length > 0;
if (!stillHasHandlers && !stillHasWaiters) return;
this.#ensureStreamTailConnected(streamId);
}, delayMs);
}
});
this.tails.set(streamId, { abortController, promise });
@@ -281,6 +388,10 @@ export class StandardInputStreamManager implements InputStreamManager {
}
#dispatch(streamId: string, data: unknown): void {
// Any record flowing through = healthy connection; reset the backoff
// counter so the next disconnect starts fresh.
this.reconnectAttempts.delete(streamId);
// First try to resolve a once waiter
const waiters = this.onceWaiters.get(streamId);
if (waiters && waiters.length > 0) {
@@ -291,6 +402,9 @@ export class StandardInputStreamManager implements InputStreamManager {
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
waiter.resolve({ ok: true, output: data });
// Also invoke persistent handlers
this.#invokeHandlers(streamId, data);
@@ -340,6 +454,13 @@ export class StandardInputStreamManager implements InputStreamManager {
}
#removeOnceWaiter(streamId: string, waiter: OnceWaiter): void {
// Centralized cleanup — both timeout and explicit abort paths funnel
// through here, so detach the abort listener once instead of at every
// callsite. The dispatch path doesn't go through this method (the
// waiter is shifted off inline), so it detaches the listener there.
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
const waiters = this.onceWaiters.get(streamId);
if (!waiters) return;
const index = waiters.indexOf(waiter);
@@ -22,6 +22,12 @@ export class NoopInputStreamManager implements InputStreamManager {
return undefined;
}
setLastSeqNum(_streamId: string, _seqNum: number): void {}
shiftBuffer(_streamId: string): boolean { return false; }
disconnectStream(_streamId: string): void {}
clearHandlers(): void {}
reset(): void {}
disconnect(): void {}
@@ -70,6 +70,28 @@ export interface InputStreamManager {
*/
lastSeqNum(streamId: string): number | undefined;
/**
* Advance the last-seen S2 sequence number for the given input stream.
* Used after `.wait()` resumes to prevent the SSE tail from replaying
* the record that was consumed via the waitpoint path.
*/
setLastSeqNum(streamId: string, seqNum: number): void;
/**
* Remove and discard the first buffered item for the given input stream.
* Used after `.wait()` resumes to remove the duplicate that the SSE tail
* buffered while the waitpoint was being completed via a separate path.
* Returns true if an item was removed, false if the buffer was empty.
*/
shiftBuffer(streamId: string): boolean;
/**
* Disconnect the SSE tail and clear the buffer for a specific input stream.
* Used before suspending via `.wait()` so the tail doesn't buffer duplicates
* of data that will be delivered through the waitpoint path.
*/
disconnectStream(streamId: string): void;
/**
* Clear all persistent `.on()` handlers and abort tails that have no remaining once waiters.
* Called automatically when a task run completes.
@@ -5,3 +5,5 @@ import { RealtimeStreamsAPI } from "./realtimeStreams/index.js";
export const realtimeStreams = RealtimeStreamsAPI.getInstance();
export * from "./realtimeStreams/types.js";
export { SessionStreamInstance } from "./realtimeStreams/sessionStreamInstance.js";
export type { SessionStreamInstanceOptions } from "./realtimeStreams/sessionStreamInstance.js";
@@ -6,6 +6,12 @@ import {
RealtimeStreamsManager,
} from "./types.js";
// Re-export the session-scoped stream instance so the SDK's
// `SessionOutputChannel.pipe` / `.writer` can construct it without reaching
// into the core package's internals.
export { SessionStreamInstance } from "./sessionStreamInstance.js";
export type { SessionStreamInstanceOptions } from "./sessionStreamInstance.js";
const API_NAME = "realtime-streams";
const NOOP_MANAGER = new NoopRealtimeStreamsManager();
@@ -6,6 +6,7 @@ import {
RealtimeStreamInstance,
RealtimeStreamOperationOptions,
RealtimeStreamsManager,
StreamWriteResult,
} from "./types.js";
export class StandardRealtimeStreamsManager implements RealtimeStreamsManager {
@@ -16,7 +17,7 @@ export class StandardRealtimeStreamsManager implements RealtimeStreamsManager {
) {}
// Track active streams - using a Set allows multiple streams for the same key to coexist
private activeStreams = new Set<{
wait: () => Promise<void>;
wait: () => Promise<StreamWriteResult>;
abortController: AbortController;
}>();
@@ -15,7 +15,7 @@ export class NoopRealtimeStreamsManager implements RealtimeStreamsManager {
options?: RealtimeStreamOperationOptions
): RealtimeStreamInstance<T> {
return {
wait: () => Promise.resolve(),
wait: () => Promise.resolve({}),
get stream(): AsyncIterableStream<T> {
return createAsyncIterableStreamFromAsyncIterable(source);
},
@@ -0,0 +1,103 @@
import { ApiClient } from "../apiClient/index.js";
import { AsyncIterableStream } from "../streams/asyncIterableStream.js";
import { AnyZodFetchOptions } from "../zodfetch.js";
import { StreamsWriterV2 } from "./streamsWriterV2.js";
import { StreamsWriter, StreamWriteResult } from "./types.js";
export type SessionStreamInstanceOptions<T> = {
apiClient: ApiClient;
baseUrl: string;
sessionId: string;
io: "out" | "in";
source: ReadableStream<T>;
signal?: AbortSignal;
requestOptions?: AnyZodFetchOptions;
debug?: boolean;
};
/**
* Session-scoped parallel to {@link StreamInstance}. Calls
* `initializeSessionStream` to fetch S2 credentials for the session's
* channel, then pipes `source` directly to S2 via {@link StreamsWriterV2}.
*
* Sessions are S2-only there's no v1 (Redis) fallback so this
* skips the version-detection dance `StreamInstance` does.
*/
export class SessionStreamInstance<T> implements StreamsWriter {
private streamPromise: Promise<StreamsWriterV2<T>>;
constructor(private options: SessionStreamInstanceOptions<T>) {
this.streamPromise = this.initializeWriter();
}
private async initializeWriter(): Promise<StreamsWriterV2<T>> {
const response = await this.options.apiClient.initializeSessionStream(
this.options.sessionId,
this.options.io,
this.options?.requestOptions
);
const headers = response.headers ?? {};
const accessToken = headers["x-s2-access-token"];
const basin = headers["x-s2-basin"];
const streamName = headers["x-s2-stream-name"];
const endpoint = headers["x-s2-endpoint"];
const flushIntervalMs = headers["x-s2-flush-interval-ms"]
? parseInt(headers["x-s2-flush-interval-ms"])
: undefined;
const maxRetries = headers["x-s2-max-retries"]
? parseInt(headers["x-s2-max-retries"])
: undefined;
if (!accessToken || !basin || !streamName) {
throw new Error(
"Session stream initialize did not return S2 credentials — server may be configured for v1 realtime streams, which sessions do not support."
);
}
return new StreamsWriterV2({
basin,
stream: streamName,
accessToken,
endpoint,
source: this.options.source,
signal: this.options.signal,
debug: this.options.debug,
flushIntervalMs,
maxRetries,
});
}
public async wait(): Promise<StreamWriteResult> {
const writer = await this.streamPromise;
return writer.wait();
}
public get stream(): AsyncIterableStream<T> {
const self = this;
return new ReadableStream<T>({
async start(controller) {
const streamWriter = await self.streamPromise;
const iterator = streamWriter[Symbol.asyncIterator]();
while (true) {
if (self.options.signal?.aborted) {
controller.close();
break;
}
const { done, value } = await iterator.next();
if (done) {
controller.close();
break;
}
controller.enqueue(value);
}
},
});
}
}
@@ -3,7 +3,7 @@ import { AsyncIterableStream } from "../streams/asyncIterableStream.js";
import { AnyZodFetchOptions } from "../zodfetch.js";
import { StreamsWriterV1 } from "./streamsWriterV1.js";
import { StreamsWriterV2 } from "./streamsWriterV2.js";
import { StreamsWriter } from "./types.js";
import { StreamsWriter, StreamWriteResult } from "./types.js";
export type StreamInstanceOptions<T> = {
apiClient: ApiClient;
@@ -63,8 +63,9 @@ export class StreamInstance<T> implements StreamsWriter {
return streamWriter;
}
public async wait(): Promise<void> {
return this.streamPromise.then((writer) => writer.wait());
public async wait(): Promise<StreamWriteResult> {
const writer = await this.streamPromise;
return writer.wait();
}
public get stream(): AsyncIterableStream<T> {
@@ -2,7 +2,7 @@ import { request as httpsRequest } from "node:https";
import { request as httpRequest } from "node:http";
import { URL } from "node:url";
import { randomBytes } from "node:crypto";
import { StreamsWriter } from "./types.js";
import { StreamsWriter, StreamWriteResult } from "./types.js";
export type StreamsWriterV1Options<T> = {
baseUrl: string;
@@ -258,8 +258,9 @@ export class StreamsWriterV1<T> implements StreamsWriter {
await this.makeRequest(0);
}
public async wait(): Promise<void> {
return this.streamPromise;
public async wait(): Promise<StreamWriteResult> {
await this.streamPromise;
return {};
}
public [Symbol.asyncIterator]() {
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { ChatChunkTooLargeError, isChatChunkTooLargeError } from "../errors.js";
import { encodeChunkOrError } from "./streamsWriterV2.js";
// The size cap and discriminant extraction are the only S2-independent bits
// of `StreamsWriterV2` that benefit from unit coverage. Both live in the
// `encodeChunkOrError` pure helper, so the tests exercise it directly — no
// `vi.mock("@s2-dev/streamstore", ...)` shim needed.
describe("encodeChunkOrError", () => {
it("flags oversize chunks and carries the chunk's `type` discriminant", () => {
const oversized = {
type: "tool-output-available",
output: { text: "x".repeat(2_000_000) },
};
const result = encodeChunkOrError(oversized);
expect(result.ok).toBe(false);
if (result.ok) return; // type guard
expect(isChatChunkTooLargeError(result.error)).toBe(true);
expect(result.error.chunkType).toBe("tool-output-available");
expect(result.error.chunkSize).toBeGreaterThan(1_000_000);
expect(result.error.maxSize).toBe(1024 * 1024 - 1024);
expect(result.error.message).toMatch(/tool-output-available/);
expect(result.error.message).toMatch(/chat\.agent chunk/);
});
it("falls back to chunk.kind when chunk.type is missing (ChatInputChunk-style)", () => {
const oversized = { kind: "action", payload: "x".repeat(2_000_000) };
const result = encodeChunkOrError(oversized);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.chunkType).toBe("action");
});
it("omits chunkType when the chunk has no discriminant", () => {
const oversized = "x".repeat(2_000_000);
const result = encodeChunkOrError(oversized);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.chunkType).toBeUndefined();
});
it("returns the encoded body for chunks under the cap", () => {
const small = { type: "text-delta", delta: "hello" };
const result = encodeChunkOrError(small);
expect(result.ok).toBe(true);
if (!result.ok) return;
const parsed = JSON.parse(result.body) as { data: unknown; id: string };
expect(parsed.data).toEqual(small);
expect(parsed.id).toMatch(/^[A-Za-z0-9_-]{7}$/); // nanoid(7)
});
});
// Cross-check the ChatChunkTooLargeError type-guard helper itself. Trivial,
// but keeps the test surface here exercising the public error helpers a
// consumer would import from the same module.
describe("isChatChunkTooLargeError", () => {
it("recognizes its own error class", () => {
const err = new ChatChunkTooLargeError(2_000_000, 1024 * 1024 - 1024, "x");
expect(isChatChunkTooLargeError(err)).toBe(true);
});
it("rejects unrelated errors", () => {
expect(isChatChunkTooLargeError(new Error("nope"))).toBe(false);
expect(isChatChunkTooLargeError("string")).toBe(false);
expect(isChatChunkTooLargeError(undefined)).toBe(false);
});
});
@@ -1,7 +1,16 @@
import { S2, AppendRecord, BatchTransform } from "@s2-dev/streamstore";
import { StreamsWriter } from "./types.js";
import { ChatChunkTooLargeError } from "../errors.js";
import { StreamsWriter, StreamWriteResult } from "./types.js";
import { nanoid } from "nanoid";
// S2 caps a single record at 1 MiB of metered bytes (body + headers + 8 byte
// overhead). We give ourselves ~1 KiB of headroom for the JSON envelope and
// metering bytes so the check fires before the SDK's internal `BatchTransform`
// rejects the record with an opaque `S2Error`.
const RECORD_BODY_MAX_BYTES = 1024 * 1024 - 1024;
const utf8Encoder = new TextEncoder();
export type StreamsWriterV2Options<T = any> = {
basin: string;
stream: string;
@@ -54,6 +63,7 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
private readonly maxInflightBytes: number;
private aborted = false;
private sessionWritable: WritableStream<any> | null = null;
private lastSeqNum: number | undefined;
constructor(private options: StreamsWriterV2Options<T>) {
this.debug = options.debug ?? false;
@@ -151,8 +161,12 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
controller.error(new Error("Stream aborted"));
return;
}
// Convert each chunk to JSON string and wrap in AppendRecord
controller.enqueue(AppendRecord.string({ body: JSON.stringify({ data: chunk, id: nanoid(7) }) }));
const encoded = encodeChunkOrError(chunk);
if (!encoded.ok) {
controller.error(encoded.error);
return;
}
controller.enqueue(AppendRecord.string({ body: encoded.body }));
},
})
)
@@ -169,9 +183,9 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
const lastAcked = session.lastAckedPosition();
if (lastAcked?.end) {
const recordsWritten = lastAcked.end.seqNum;
this.lastSeqNum = lastAcked.end.seqNum;
this.log(
`[S2MetadataStream] Written ${recordsWritten} records, ending at seqNum=${lastAcked.end.seqNum}`
`[S2MetadataStream] Written ${this.lastSeqNum} records, ending at seqNum=${this.lastSeqNum}`
);
}
} catch (error) {
@@ -184,8 +198,9 @@ export class StreamsWriterV2<T = any> implements StreamsWriter {
}
}
public async wait(): Promise<void> {
public async wait(): Promise<StreamWriteResult> {
await this.streamPromise;
return { lastEventId: this.lastSeqNum?.toString() };
}
public [Symbol.asyncIterator]() {
@@ -225,3 +240,43 @@ function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
reader.releaseLock();
} catch (error) {}
}
// chat.agent emits two chunk shapes through this writer:
// - UIMessageChunks + custom data parts: `{ type: "tool-output-available" | "data-..." | ... }`
// - ChatInputChunks (mostly seen on `.in`, but reused as the discriminant
// elsewhere): `{ kind: "message" | "stop" | "action" }`
// Surfacing whichever discriminant exists turns "chunk too large" into
// "tool-output-available chunk too large", which is what users actually need.
function extractChunkType(chunk: unknown): string | undefined {
if (!chunk || typeof chunk !== "object") return undefined;
const c = chunk as { type?: unknown; kind?: unknown };
if (typeof c.type === "string") return c.type;
if (typeof c.kind === "string") return c.kind;
return undefined;
}
/**
* Encode a chunk as a JSON record body for S2, enforcing the per-record
* size cap. Exported so the size/discriminant logic can be unit-tested
* directly without spinning up an S2 client or mocking `@s2-dev/streamstore`.
*
* Returns `{ ok: true, body }` when the encoded chunk fits within
* `RECORD_BODY_MAX_BYTES`, or `{ ok: false, error }` carrying a
* `ChatChunkTooLargeError` annotated with the chunk's discriminant
* (`type` or `kind`, whichever is present) so the surfaced error is
* useful "tool-output-available chunk too large" beats a bare
* "chunk too large" by a lot.
*/
export function encodeChunkOrError(
chunk: unknown
): { ok: true; body: string } | { ok: false; error: ChatChunkTooLargeError } {
const body = JSON.stringify({ data: chunk, id: nanoid(7) });
const size = utf8Encoder.encode(body).length;
if (size > RECORD_BODY_MAX_BYTES) {
return {
ok: false,
error: new ChatChunkTooLargeError(size, RECORD_BODY_MAX_BYTES, extractChunkType(chunk)),
};
}
return { ok: true, body };
}
+39 -3
View File
@@ -26,13 +26,17 @@ export interface RealtimeStreamsManager {
): Promise<void>;
}
export type StreamWriteResult = {
lastEventId?: string;
};
export interface RealtimeStreamInstance<T> {
wait(): Promise<void>;
wait(): Promise<StreamWriteResult>;
get stream(): AsyncIterableStream<T>;
}
export interface StreamsWriter {
wait(): Promise<void>;
wait(): Promise<StreamWriteResult>;
}
export type RealtimeDefinedStream<TPart> = {
@@ -71,6 +75,10 @@ export type PipeStreamOptions = {
* Additional request options for the API call.
*/
requestOptions?: ApiRequestOptions;
/** Override the default span name for this operation. */
spanName?: string;
/** When true, the span will be collapsed in the dashboard. */
collapsed?: boolean;
};
/**
@@ -89,7 +97,7 @@ export type PipeStreamResult<T> = {
* to the realtime stream. Use this to wait for the stream to complete before
* finishing your task.
*/
waitUntilComplete: () => Promise<void>;
waitUntilComplete: () => Promise<StreamWriteResult>;
};
/**
@@ -185,6 +193,14 @@ export type RealtimeDefinedInputStream<TData> = {
* Uses a waitpoint token internally. Can only be called inside a task.run().
*/
wait: (options?: InputStreamWaitOptions) => ManualWaitpointPromise<TData>;
/**
* Wait for data with an idle phase before suspending.
*
* Keeps the task active (using compute) for `idleTimeoutInSeconds`,
* then suspends via `.wait()` if no data arrives. If data arrives during
* the idle phase the task responds instantly without suspending.
*/
waitWithIdleTimeout: (options: InputStreamWaitWithIdleTimeoutOptions) => Promise<{ ok: true; output: TData } | { ok: false; error?: any }>;
/**
* Send data to this input stream on a specific run.
* This is used from outside the task (e.g., from your backend or another task).
@@ -199,6 +215,8 @@ export type InputStreamSubscription = {
export type InputStreamOnceOptions = {
signal?: AbortSignal;
timeoutMs?: number;
/** Override the default span name for this operation. */
spanName?: string;
};
export type SendInputStreamOptions = {
@@ -234,6 +252,24 @@ export type InputStreamWaitOptions = {
* and filtering waitpoints via `wait.listTokens()`.
*/
tags?: string[];
/** Override the default span name for this operation. */
spanName?: string;
};
export type InputStreamWaitWithIdleTimeoutOptions = {
/** Seconds to keep the task idle (active, using compute) before suspending. */
idleTimeoutInSeconds: number;
/** Maximum time to wait after suspending (duration string, e.g. "1h"). */
timeout?: string;
/** Override the default span name for the outer operation. */
spanName?: string;
/** Called right before suspending (after idle phase times out). Not called if data arrives during idle. */
onSuspend?: () => void | Promise<void>;
/** Called right after resuming from suspension with data. Not called if data arrived during idle or on timeout. */
onResume?: () => void | Promise<void>;
/** When true, skip the suspend phase entirely. If idle times out, return `{ ok: false }` immediately. */
skipSuspend?: boolean;
};
export type InferInputStreamType<T> = T extends RealtimeDefinedInputStream<infer TData>
+31
View File
@@ -1115,6 +1115,7 @@ const CommonRunFields = {
baseCostInCents: z.number(),
durationMs: z.number(),
metadata: z.record(z.any()).optional(),
taskKind: z.string().optional(),
};
const RetrieveRunCommandFields = {
@@ -1494,6 +1495,12 @@ export const SessionTriggerConfig = z.object({
queue: z.string().max(128).optional(),
tags: z.array(z.string().max(128)).max(5).optional(),
maxAttempts: z.number().int().positive().max(10).optional(),
/** Per-run wall-clock cap (seconds). Forwarded to `TaskRunOptions.maxDuration`. */
maxDuration: z.number().int().positive().optional(),
/** Pin every run to a specific worker version. Forwarded to `TaskRunOptions.lockToVersion`. */
lockToVersion: z.string().optional(),
/** Region to schedule runs in. Forwarded to `TaskRunOptions.region`. */
region: z.string().optional(),
/** Convenience field surfaced to chat.agent via the wire payload. */
idleTimeoutInSeconds: z.number().int().positive().max(3600).optional(),
});
@@ -1818,6 +1825,9 @@ export const ApiDeploymentListResponseItem = z.object({
export type ApiDeploymentListResponseItem = z.infer<typeof ApiDeploymentListResponseItem>;
export const RetrieveCurrentDeploymentResponseBody = ApiDeploymentListResponseItem;
export type RetrieveCurrentDeploymentResponseBody = ApiDeploymentListResponseItem;
export const ApiBranchListResponseBody = z.object({
branches: z.array(
z.object({
@@ -1938,6 +1948,27 @@ export const SendInputStreamResponseBody = z.object({
});
export type SendInputStreamResponseBody = z.infer<typeof SendInputStreamResponseBody>;
/**
* Response body for `GET /realtime/v1/sessions/:id/:io/records`. A non-SSE,
* `wait=0` drain of a session channel used at run boot for snapshot
* replay where the SSE long-poll tax (~1s on empty streams) was the
* dominant cost. The shape mirrors the webapp's internal `StreamRecord`
* type (`apps/webapp/app/services/realtime/types.ts`); each record's
* `data` is a JSON-encoded chunk body that callers parse client-side.
*/
export const ReadSessionStreamRecordsResponseBody = z.object({
records: z.array(
z.object({
data: z.string(),
id: z.string(),
seqNum: z.number(),
})
),
});
export type ReadSessionStreamRecordsResponseBody = z.infer<
typeof ReadSessionStreamRecordsResponseBody
>;
export const ResolvePromptRequestBody = z.object({
variables: z.record(z.unknown()).default({}),
label: z.string().optional(),
+10 -1
View File
@@ -1,6 +1,12 @@
import { z } from "zod";
import { ConfigManifest } from "./config.js";
import { PromptManifest, QueueManifest, TaskFile, TaskManifest } from "./schemas.js";
import {
PromptManifest,
QueueManifest,
SkillManifest,
TaskFile,
TaskManifest,
} from "./schemas.js";
export const BuildExternal = z.object({
name: z.string(),
@@ -70,6 +76,8 @@ export const BuildManifest = z.object({
.optional(),
/** Maps output file paths to their content hashes for deduplication during dev */
outputHashes: z.record(z.string()).optional(),
/** Skills discovered and bundled into `.trigger/skills/{id}/` under `outputPath`. */
skills: SkillManifest.array().optional(),
});
export type BuildManifest = z.infer<typeof BuildManifest>;
@@ -87,6 +95,7 @@ export const WorkerManifest = z.object({
configPath: z.string(),
tasks: TaskManifest.array(),
prompts: PromptManifest.array().optional(),
skills: SkillManifest.array().optional(),
queues: QueueManifest.array().optional(),
workerEntryPoint: z.string(),
controllerEntryPoint: z.string().optional(),
@@ -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>;
+27
View File
@@ -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(),
@@ -241,6 +246,28 @@ export const PromptManifest = z.object({
export type PromptManifest = z.infer<typeof PromptManifest>;
// ── Skills ────────────────────────────────────────────────────────────────
//
// A skill is a developer-authored folder (SKILL.md + scripts/references/assets)
// bundled into the deploy image. SkillMetadata is registered at module load
// by `ai.defineSkill({ id, path })`; the CLI's built-in bundler picks it up
// during deploy and copies the folder into the deploy image.
const skillMetadata = {
id: z.string(),
/** Path to the skill's source folder, relative to the project root. */
sourcePath: z.string(),
};
export const SkillMetadata = z.object(skillMetadata);
export type SkillMetadata = z.infer<typeof SkillMetadata>;
export const SkillManifest = z.object({
...skillMetadata,
...taskFileMetadata,
});
export type SkillManifest = z.infer<typeof SkillManifest>;
export const PostStartCauses = z.enum(["index", "create", "restore"]);
export type PostStartCauses = z.infer<typeof PostStartCauses>;
@@ -13,6 +13,7 @@ export const SemanticInternalAttributes = {
RUN_ID: "ctx.run.id",
RUN_IS_TEST: "ctx.run.isTest",
RUN_IS_REPLAY: "ctx.run.isReplay",
GEN_AI_CONVERSATION_ID: "gen_ai.conversation.id",
ORIGINAL_RUN_ID: "$original_run_id",
BATCH_ID: "ctx.batch.id",
TASK_SLUG: "ctx.task.id",
@@ -0,0 +1,7 @@
// Split module-level variable definition into separate files to allow
// tree-shaking on each api instance.
import { SessionStreamsAPI } from "./sessionStreams/index.js";
export const sessionStreams = SessionStreamsAPI.getInstance();
export * from "./sessionStreams/types.js";
@@ -0,0 +1,89 @@
import { getGlobal, registerGlobal } from "../utils/globals.js";
import { NoopSessionStreamManager } from "./noopManager.js";
import {
InputStreamOncePromise,
SessionChannelIO,
SessionStreamManager,
} from "./types.js";
import { InputStreamOnceOptions } from "../realtimeStreams/types.js";
const API_NAME = "session-streams";
const NOOP_MANAGER = new NoopSessionStreamManager();
export class SessionStreamsAPI implements SessionStreamManager {
private static _instance?: SessionStreamsAPI;
private constructor() {}
public static getInstance(): SessionStreamsAPI {
if (!this._instance) {
this._instance = new SessionStreamsAPI();
}
return this._instance;
}
setGlobalManager(manager: SessionStreamManager): boolean {
return registerGlobal(API_NAME, manager);
}
#getManager(): SessionStreamManager {
return getGlobal(API_NAME) ?? NOOP_MANAGER;
}
public on(
sessionId: string,
io: SessionChannelIO,
handler: (data: unknown) => void | Promise<void>
): { off: () => void } {
return this.#getManager().on(sessionId, io, handler);
}
public once(
sessionId: string,
io: SessionChannelIO,
options?: InputStreamOnceOptions
): InputStreamOncePromise<unknown> {
return this.#getManager().once(sessionId, io, options);
}
public peek(sessionId: string, io: SessionChannelIO): unknown | undefined {
return this.#getManager().peek(sessionId, io);
}
public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
return this.#getManager().lastSeqNum(sessionId, io);
}
public setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
this.#getManager().setLastSeqNum(sessionId, io, seqNum);
}
public setMinTimestamp(
sessionId: string,
io: SessionChannelIO,
minTimestamp: number | undefined
): void {
this.#getManager().setMinTimestamp(sessionId, io, minTimestamp);
}
public shiftBuffer(sessionId: string, io: SessionChannelIO): boolean {
return this.#getManager().shiftBuffer(sessionId, io);
}
public disconnectStream(sessionId: string, io: SessionChannelIO): void {
this.#getManager().disconnectStream(sessionId, io);
}
public clearHandlers(): void {
this.#getManager().clearHandlers();
}
public reset(): void {
this.#getManager().reset();
}
public disconnect(): void {
this.#getManager().disconnect();
}
}
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { StandardSessionStreamManager } from "./manager.js";
import type { ApiClient } from "../apiClient/index.js";
import type { SSEStreamPart } from "../apiClient/runStream.js";
// Single-shot mock that mimics S2's long-poll: delivers `records` once via
// `onPart` on the first subscribe call, then keeps the returned async
// iterable OPEN until the abort signal fires. Real S2 keeps the SSE
// connection alive on a long-poll; the manager's `runTail` finally /
// reconnect path only fires when the connection actually closes. Returning
// an empty stream synchronously triggers a tight reconnect loop, so the
// mock parks indefinitely instead.
function singleShotApiClient(
records: Array<{ id: string; chunk: unknown; timestamp: number }>
): ApiClient {
let delivered = false;
return {
async subscribeToSessionStream<T>(
_sessionIdOrExternalId: string,
_io: "out" | "in",
options?: { onPart?: (part: SSEStreamPart<T>) => void; signal?: AbortSignal }
) {
if (!delivered) {
delivered = true;
for (const record of records) {
options?.onPart?.(record as SSEStreamPart<T>);
}
}
const signal = options?.signal;
return (async function* () {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
if (!signal) {
// No signal — block the stream forever; tests must
// explicitly call `disconnectStream` / `disconnect` to
// unblock.
return;
}
signal.addEventListener("abort", () => resolve(), { once: true });
});
})() as unknown as Awaited<ReturnType<ApiClient["subscribeToSessionStream"]>>;
},
} as unknown as ApiClient;
}
describe("StandardSessionStreamManager — minTimestamp filter", () => {
const sessionId = "session-1";
const io = "in" as const;
it("dispatches records when no filter is set", async () => {
const records = [
{ id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 },
{ id: "1", chunk: { kind: "message", payload: { id: "u2" } }, timestamp: 2000 },
];
const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost");
const first = await manager.once(sessionId, io);
expect(first).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } });
const second = await manager.once(sessionId, io);
expect(second).toEqual({ ok: true, output: { kind: "message", payload: { id: "u2" } } });
manager.disconnectStream(sessionId, io); // stop reconnect loop
manager.disconnect();
});
it("drops records whose timestamp is <= minTimestamp", async () => {
const records = [
{ id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 },
{ id: "1", chunk: { kind: "message", payload: { id: "u2" } }, timestamp: 2000 },
{ id: "2", chunk: { kind: "message", payload: { id: "u3" } }, timestamp: 3000 },
];
const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost");
// Cutoff at 2000 (inclusive: `<=` is dropped). Only u3 should pass.
manager.setMinTimestamp(sessionId, io, 2000);
const passed = await manager.once(sessionId, io, { timeoutMs: 200 });
expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u3" } } });
manager.disconnectStream(sessionId, io);
manager.disconnect();
});
it("clears the filter when set to undefined", async () => {
const records = [
{ id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 },
];
const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost");
manager.setMinTimestamp(sessionId, io, 5000);
manager.setMinTimestamp(sessionId, io, undefined);
const passed = await manager.once(sessionId, io, { timeoutMs: 200 });
expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } });
manager.disconnectStream(sessionId, io);
manager.disconnect();
});
it("filter is per-(sessionId, io) and doesn't bleed across streams", async () => {
const inApi = singleShotApiClient([
{ id: "0", chunk: { kind: "in-record" }, timestamp: 1000 },
]);
const manager = new StandardSessionStreamManager(inApi, "http://localhost");
manager.setMinTimestamp(sessionId, "in", 5000);
// The "out" stream uses the same singleShotApiClient instance — its
// single-shot delivers the same fixture, but the filter doesn't apply
// to "out" so the record passes.
const outResult = await manager.once(sessionId, "out", { timeoutMs: 200 });
expect(outResult).toEqual({ ok: true, output: { kind: "in-record" } });
// The "in" stream is filtered (minTimestamp=5000, record ts=1000): the
// once() call should idle-timeout instead of resolving with the record.
// But the singleShot instance has already delivered to the "out" tail,
// so the "in" tail will get nothing on first connect anyway. Use a
// separate manager+api to keep the assertion crisp.
const inApi2 = singleShotApiClient([
{ id: "0", chunk: { kind: "in-record-2" }, timestamp: 1000 },
]);
const manager2 = new StandardSessionStreamManager(inApi2, "http://localhost");
manager2.setMinTimestamp(sessionId, "in", 5000);
const inResult = await manager2.once(sessionId, "in", { timeoutMs: 100 });
expect(inResult.ok).toBe(false); // filter-dropped → idle timeout
manager.disconnectStream(sessionId, "in");
manager.disconnectStream(sessionId, "out");
manager.disconnect();
manager2.disconnectStream(sessionId, "in");
manager2.disconnect();
});
it("reset() clears all per-stream timestamp filters", async () => {
const records = [
{ id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 },
];
const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost");
manager.setMinTimestamp(sessionId, io, 5000);
manager.reset();
const passed = await manager.once(sessionId, io, { timeoutMs: 200 });
expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } });
manager.disconnectStream(sessionId, io);
manager.disconnect();
});
});
@@ -0,0 +1,478 @@
import { ApiClient } from "../apiClient/index.js";
import {
InputStreamOncePromise,
InputStreamOnceResult,
InputStreamTimeoutError,
} from "../inputStreams/types.js";
import { InputStreamOnceOptions } from "../realtimeStreams/types.js";
import { computeReconnectDelayMs } from "../utils/reconnectBackoff.js";
import { SessionChannelIO, SessionStreamManager } from "./types.js";
type SessionStreamHandler = (data: unknown) => void | Promise<void>;
type OnceWaiter = {
resolve: (result: InputStreamOnceResult<unknown>) => void;
reject: (error: Error) => void;
timeoutHandle?: ReturnType<typeof setTimeout>;
// The abort signal and its handler are tracked on the waiter so any
// resolution path (dispatch / timeout / explicit removal) can detach
// the listener. Without this, a long-lived `AbortSignal` reused across
// many `once()` calls accumulates listeners — `{ once: true }` only
// self-clears if the signal actually aborts.
signal?: AbortSignal;
abortHandler?: () => void;
};
type TailState = {
abortController: AbortController;
promise: Promise<void>;
};
function keyFor(sessionId: string, io: SessionChannelIO): string {
return `${sessionId}:${io}`;
}
/**
* Session-scoped parallel to {@link StandardInputStreamManager}. Keeps the
* same buffer / once-waiter / tail lifecycle, but keyed on
* `(sessionId, io)` and subscribing via
* {@link ApiClient.subscribeToSessionStream} instead of the run input
* stream SSE.
*/
export class StandardSessionStreamManager implements SessionStreamManager {
private handlers = new Map<string, Set<SessionStreamHandler>>();
private onceWaiters = new Map<string, OnceWaiter[]>();
private buffer = new Map<string, unknown[]>();
private tails = new Map<string, TailState>();
// Per-stream lower-bound timestamp filter. When set, records whose
// SSE timestamp is <= the bound are dropped before dispatch — used by
// chat.agent on OOM-retry boot to skip session.in records belonging
// to turns that already completed on the prior attempt. The filter
// is consulted in `runTail`'s `onPart` so the buffer never sees the
// dropped records.
private minTimestamps = new Map<string, number>();
// Keys that were explicitly torn down by `disconnectStream`. The tail's
// `.finally` reconnect path checks this so a long-lived persistent handler
// (e.g. `chat.agent`'s run-level `stopInput.on(...)`) doesn't silently
// resurrect the tail mid-`session.in.wait()` and re-deliver the record
// that's already being delivered out-of-band via the waitpoint.
private explicitlyDisconnected = new Set<string>();
private seqNums = new Map<string, number>();
// Reconnect attempt counter per key. Drives the exponential backoff
// applied by `#ensureTailConnected`'s `.finally` so a persistent
// backend failure (auth rejection, 5xx, DNS, etc.) doesn't reconnect
// in a tight loop. Reset to 0 by `#dispatch` whenever a real record
// flows through — any successful traffic is taken as a healthy
// connection.
private reconnectAttempts = new Map<string, number>();
constructor(
private apiClient: ApiClient,
private baseUrl: string,
private debug: boolean = false
) {}
on(
sessionId: string,
io: SessionChannelIO,
handler: SessionStreamHandler
): { off: () => void } {
const key = keyFor(sessionId, io);
let handlerSet = this.handlers.get(key);
if (!handlerSet) {
handlerSet = new Set();
this.handlers.set(key, handlerSet);
}
handlerSet.add(handler);
// Explicit re-attach clears the "explicitly disconnected" suppression
// so the tail can subscribe again now that callers want delivery back.
this.explicitlyDisconnected.delete(key);
this.#ensureTailConnected(sessionId, io);
const buffered = this.buffer.get(key);
if (buffered && buffered.length > 0) {
for (const data of buffered) {
this.#invokeHandler(handler, data);
}
this.buffer.delete(key);
}
return {
off: () => {
handlerSet?.delete(handler);
if (handlerSet?.size === 0) {
this.handlers.delete(key);
}
},
};
}
once(
sessionId: string,
io: SessionChannelIO,
options?: InputStreamOnceOptions
): InputStreamOncePromise<unknown> {
const key = keyFor(sessionId, io);
this.explicitlyDisconnected.delete(key);
this.#ensureTailConnected(sessionId, io);
const buffered = this.buffer.get(key);
if (buffered && buffered.length > 0) {
const data = buffered.shift()!;
if (buffered.length === 0) {
this.buffer.delete(key);
}
return new InputStreamOncePromise((resolve) => {
resolve({ ok: true, output: data });
});
}
return new InputStreamOncePromise<unknown>((resolve, reject) => {
const waiter: OnceWaiter = { resolve, reject };
if (options?.signal) {
if (options.signal.aborted) {
reject(new Error("Aborted"));
return;
}
const abortHandler = () => {
if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle);
this.#removeOnceWaiter(key, waiter);
reject(new Error("Aborted"));
};
waiter.signal = options.signal;
waiter.abortHandler = abortHandler;
options.signal.addEventListener("abort", abortHandler, { once: true });
}
if (options?.timeoutMs) {
waiter.timeoutHandle = setTimeout(() => {
this.#removeOnceWaiter(key, waiter);
resolve({
ok: false,
error: new InputStreamTimeoutError(key, options.timeoutMs!),
});
}, options.timeoutMs);
}
let waiters = this.onceWaiters.get(key);
if (!waiters) {
waiters = [];
this.onceWaiters.set(key, waiters);
}
waiters.push(waiter);
});
}
peek(sessionId: string, io: SessionChannelIO): unknown | undefined {
const buffered = this.buffer.get(keyFor(sessionId, io));
if (buffered && buffered.length > 0) return buffered[0];
return undefined;
}
lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
return this.seqNums.get(keyFor(sessionId, io));
}
setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
const key = keyFor(sessionId, io);
const current = this.seqNums.get(key);
if (current === undefined || seqNum > current) {
this.seqNums.set(key, seqNum);
}
}
setMinTimestamp(
sessionId: string,
io: SessionChannelIO,
minTimestamp: number | undefined
): void {
const key = keyFor(sessionId, io);
if (minTimestamp === undefined) {
this.minTimestamps.delete(key);
} else {
this.minTimestamps.set(key, minTimestamp);
}
}
shiftBuffer(sessionId: string, io: SessionChannelIO): boolean {
const key = keyFor(sessionId, io);
const buffered = this.buffer.get(key);
if (buffered && buffered.length > 0) {
buffered.shift();
if (buffered.length === 0) this.buffer.delete(key);
return true;
}
return false;
}
disconnectStream(sessionId: string, io: SessionChannelIO): void {
const key = keyFor(sessionId, io);
const tail = this.tails.get(key);
const bufferedSize = this.buffer.get(key)?.length ?? 0;
// Mark as explicitly disconnected BEFORE we abort, so the tail's
// `.finally` reconnect path sees the flag when it runs (which can be
// synchronous in the AbortError catch). Cleared on the next explicit
// `on()`/`once()`.
this.explicitlyDisconnected.add(key);
if (tail) {
tail.abortController.abort();
this.tails.delete(key);
}
this.buffer.delete(key);
// Reset the backoff counter so a future re-attach starts fresh —
// an explicit disconnect is a deliberate teardown, not evidence of
// a broken backend.
this.reconnectAttempts.delete(key);
}
clearHandlers(): void {
this.handlers.clear();
for (const [key, tail] of this.tails) {
const hasWaiters = this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0;
if (!hasWaiters) {
tail.abortController.abort();
this.tails.delete(key);
}
}
}
/**
* Tear down all active tails. Does NOT clear handlers or `onceWaiters`,
* so any registered listener will trigger an auto-reconnect (with
* backoff) the moment it sees no live tail by design, so a transient
* network blip recovers without the caller re-subscribing. Use
* `reset()` if you want a full clean state with no resurrection, or
* `disconnectStream(sessionId, io)` for a single channel that should
* stay down until a fresh `on()` / `once()` attaches.
*/
disconnect(): void {
for (const [, tail] of this.tails) {
tail.abortController.abort();
}
this.tails.clear();
}
reset(): void {
this.disconnect();
this.seqNums.clear();
this.minTimestamps.clear();
this.handlers.clear();
this.reconnectAttempts.clear();
for (const [, waiters] of this.onceWaiters) {
for (const waiter of waiters) {
if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle);
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
waiter.reject(new Error("Session stream manager reset"));
}
}
this.onceWaiters.clear();
this.buffer.clear();
}
#ensureTailConnected(sessionId: string, io: SessionChannelIO): void {
const key = keyFor(sessionId, io);
if (this.tails.has(key)) return;
const abortController = new AbortController();
const promise = this.#runTail(sessionId, io, abortController.signal)
.catch((error) => {
if (this.debug) {
console.error(`[SessionStreamManager] Tail error for "${key}":`, error);
}
})
.finally(() => {
this.tails.delete(key);
// If the tail was torn down explicitly via `disconnectStream`,
// honor that — the caller (typically `session.in.wait()`) is
// suspending the run and expects no records to be buffered or
// delivered until a fresh `on()` / `once()` re-attaches. Without
// this guard a run-level persistent handler (e.g. `chat.agent`'s
// `stopInput.on(...)`) would auto-reconnect during the suspend
// window, the resurrected tail would receive the same record the
// waitpoint just delivered, and that record would land in the
// buffer where the next turn's `messagesInput.on(...)` drains it
// and runs a duplicate turn.
if (this.explicitlyDisconnected.has(key)) {
return;
}
const hasHandlers = this.handlers.has(key) && this.handlers.get(key)!.size > 0;
const hasWaiters =
this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0;
if (hasHandlers || hasWaiters) {
// Exponential backoff with jitter. 1s base, doubling each
// attempt, capped at 30s. Without this, a persistent backend
// failure (auth rejected, 5xx, DNS) reconnects in a tight loop
// because `#runTail`'s error path only logs. `#dispatch` resets
// the counter on every successful record, so transient blips
// don't accumulate.
const attempt = this.reconnectAttempts.get(key) ?? 0;
this.reconnectAttempts.set(key, attempt + 1);
const delayMs = computeReconnectDelayMs(attempt);
setTimeout(() => {
// Guards: a fresh `on()` during the wait may already have
// re-attached the tail; explicit disconnect or absence of
// handlers/waiters means we should stay quiet.
if (this.tails.has(key)) return;
if (this.explicitlyDisconnected.has(key)) return;
const stillHasHandlers =
this.handlers.has(key) && this.handlers.get(key)!.size > 0;
const stillHasWaiters =
this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0;
if (!stillHasHandlers && !stillHasWaiters) return;
this.#ensureTailConnected(sessionId, io);
}, delayMs);
}
});
this.tails.set(key, { abortController, promise });
}
async #runTail(
sessionId: string,
io: SessionChannelIO,
signal: AbortSignal
): Promise<void> {
const key = keyFor(sessionId, io);
try {
const lastSeq = this.seqNums.get(key);
// Dispatch is driven from `onPart` (not the for-await loop) so each
// record reaches dispatch with its full SSE metadata in scope —
// specifically the timestamp, which we need for the per-stream
// min-timestamp filter. The for-await loop below just drains the
// pipeThrough output to keep the source flowing.
const stream = await this.apiClient.subscribeToSessionStream<unknown>(sessionId, io, {
signal,
baseUrl: this.baseUrl,
timeoutInSeconds: 600,
lastEventId: lastSeq !== undefined ? String(lastSeq) : undefined,
onPart: (part) => {
if (signal.aborted) return;
const seqNum = parseInt(part.id, 10);
if (Number.isFinite(seqNum)) {
this.seqNums.set(key, seqNum);
}
// Min-timestamp filter: drop records older than (or at) the
// bound. Used to skip already-processed records on OOM-retry
// boot.
const minTs = this.minTimestamps.get(key);
if (minTs !== undefined && part.timestamp <= minTs) {
return;
}
let data: unknown = part.chunk;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// keep as string
}
}
this.#dispatch(key, data);
},
onComplete: () => {
if (this.debug) {
console.log(`[SessionStreamManager] Tail completed for "${key}"`);
}
},
onError: (error) => {
if (this.debug) {
console.error(`[SessionStreamManager] Tail error for "${key}":`, error);
}
},
});
// Drain to keep the pipeThrough flowing. Records were already
// dispatched in `onPart`, so the body here is a no-op.
for await (const _record of stream) {
if (signal.aborted) break;
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
throw error;
}
}
#dispatch(key: string, data: unknown): void {
// Any record flowing through = healthy connection; reset the backoff
// counter so the next disconnect starts fresh.
this.reconnectAttempts.delete(key);
const waiters = this.onceWaiters.get(key);
if (waiters && waiters.length > 0) {
const waiter = waiters.shift()!;
if (waiters.length === 0) this.onceWaiters.delete(key);
if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle);
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
waiter.resolve({ ok: true, output: data });
this.#invokeHandlers(key, data);
return;
}
// Persistent handlers (e.g. `stopInput.on(...)`) get a copy of the chunk,
// but they don't "consume" it — handlers usually filter by `kind` and
// ignore chunks they don't care about. Buffer the chunk regardless so a
// subsequent `once()` (e.g. `messagesInput.waitWithIdleTimeout` in
// chat.agent's preload) can still pick up the same chunk that arrived
// before its waiter was registered.
this.#invokeHandlers(key, data);
let buffered = this.buffer.get(key);
if (!buffered) {
buffered = [];
this.buffer.set(key, buffered);
}
buffered.push(data);
}
#invokeHandlers(key: string, data: unknown): void {
const handlers = this.handlers.get(key);
if (!handlers) return;
for (const handler of handlers) {
this.#invokeHandler(handler, data);
}
}
#invokeHandler(handler: SessionStreamHandler, data: unknown): void {
try {
const result = handler(data);
if (result && typeof result === "object" && "catch" in result) {
(result as Promise<void>).catch((error) => {
if (this.debug) {
console.error("[SessionStreamManager] Handler error:", error);
}
});
}
} catch (error) {
if (this.debug) {
console.error("[SessionStreamManager] Handler error:", error);
}
}
}
#removeOnceWaiter(key: string, waiter: OnceWaiter): void {
// Centralized cleanup — both timeout and explicit abort paths funnel
// through here, so detach the abort listener once instead of at every
// callsite. The dispatch path doesn't go through this method (the
// waiter is shifted off inline), so it detaches the listener there.
if (waiter.signal && waiter.abortHandler) {
waiter.signal.removeEventListener("abort", waiter.abortHandler);
}
const waiters = this.onceWaiters.get(key);
if (!waiters) return;
const index = waiters.indexOf(waiter);
if (index !== -1) waiters.splice(index, 1);
if (waiters.length === 0) this.onceWaiters.delete(key);
}
}
@@ -0,0 +1,51 @@
import { InputStreamOnceOptions } from "../realtimeStreams/types.js";
import { InputStreamOncePromise } from "../inputStreams/types.js";
import { SessionChannelIO, SessionStreamManager } from "./types.js";
export class NoopSessionStreamManager implements SessionStreamManager {
on(
_sessionId: string,
_io: SessionChannelIO,
_handler: (data: unknown) => void | Promise<void>
): { off: () => void } {
return { off: () => {} };
}
once(
_sessionId: string,
_io: SessionChannelIO,
_options?: InputStreamOnceOptions
): InputStreamOncePromise<unknown> {
return new InputStreamOncePromise(() => {
// Never resolves in noop mode.
});
}
peek(_sessionId: string, _io: SessionChannelIO): unknown | undefined {
return undefined;
}
lastSeqNum(_sessionId: string, _io: SessionChannelIO): number | undefined {
return undefined;
}
setLastSeqNum(_sessionId: string, _io: SessionChannelIO, _seqNum: number): void {}
setMinTimestamp(
_sessionId: string,
_io: SessionChannelIO,
_minTimestamp: number | undefined
): void {}
shiftBuffer(_sessionId: string, _io: SessionChannelIO): boolean {
return false;
}
disconnectStream(_sessionId: string, _io: SessionChannelIO): void {}
clearHandlers(): void {}
reset(): void {}
disconnect(): void {}
}
@@ -0,0 +1,76 @@
import { InputStreamOnceOptions } from "../realtimeStreams/types.js";
import {
InputStreamOncePromise,
InputStreamOnceResult,
InputStreamTimeoutError,
} from "../inputStreams/types.js";
/**
* Re-export the run-scoped input stream once-promise machinery so callers
* depending on sessionStreams don't also need to import from inputStreams.
* Both APIs return the same shape.
*/
export { InputStreamOncePromise, InputStreamTimeoutError };
export type { InputStreamOnceResult };
export type SessionChannelIO = "out" | "in";
/**
* Manager for Session channel reads: a session-scoped parallel to
* {@link InputStreamManager} keyed on `(sessionId, io)` instead of
* `(runId, streamId)`. Used by {@link SessionChannel} to implement
* `.on` / `.once` / `.peek` / `.wait` / `.waitWithIdleTimeout`.
*/
export interface SessionStreamManager {
/** Register a handler that fires every time data arrives on the given channel. */
on(
sessionId: string,
io: SessionChannelIO,
handler: (data: unknown) => void | Promise<void>
): { off: () => void };
/** Wait for the next record on the given channel (buffered or live). */
once(
sessionId: string,
io: SessionChannelIO,
options?: InputStreamOnceOptions
): InputStreamOncePromise<unknown>;
/** Non-blocking peek at the head of the channel buffer. */
peek(sessionId: string, io: SessionChannelIO): unknown | undefined;
/** Last S2 sequence number seen on the given channel. */
lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined;
/** Advance the last-seen sequence number (prevents SSE replay after `.wait` resume). */
setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void;
/**
* Set a per-stream lower-bound SSE timestamp. Records whose timestamp
* is `<= minTimestamp` are dropped before dispatch. Used by chat.agent
* on OOM-retry boot to skip session.in records belonging to turns
* that already completed on the prior attempt.
*
* Pass `undefined` to clear the filter.
*/
setMinTimestamp(
sessionId: string,
io: SessionChannelIO,
minTimestamp: number | undefined
): void;
/** Remove and discard the first buffered record. Returns true if one was removed. */
shiftBuffer(sessionId: string, io: SessionChannelIO): boolean;
/** Abort the SSE tail and clear the buffer. Called before `.wait` suspends. */
disconnectStream(sessionId: string, io: SessionChannelIO): void;
/** Clear all `.on` handlers; abort tails without pending once-waiters. */
clearHandlers(): void;
/** Reset state between task executions. */
reset(): void;
/** Disconnect every tail. */
disconnect(): void;
}
@@ -0,0 +1,219 @@
import type { InputStreamManager, InputStreamOnceResult } from "../inputStreams/types.js";
import { InputStreamOncePromise, InputStreamTimeoutError } from "../inputStreams/types.js";
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
type OnceWaiter = {
resolve: (value: InputStreamOnceResult<unknown>) => void;
timer?: ReturnType<typeof setTimeout>;
signal?: AbortSignal;
abortHandler?: () => void;
};
type Handler = (data: unknown) => void | Promise<void>;
/**
* In-memory implementation of `InputStreamManager` for unit tests.
*
* Tests push data via the driver's `.send(streamId, data)` method. Any
* pending `.once()` waiters resolve immediately, and all `.on()` handlers
* fire synchronously (awaited if they return a promise).
*
* Use this alongside {@link runInMockTaskContext} not directly.
*/
export class TestInputStreamManager implements InputStreamManager {
private handlers = new Map<string, Set<Handler>>();
private onceWaiters = new Map<string, OnceWaiter[]>();
private latest = new Map<string, unknown>();
private lastSeqNums = new Map<string, number>();
// Buffered sends that arrived before a `.once()` waiter was registered.
// `.once()` semantically means "wait for NEXT value" but tests often
// send data before the task has had a chance to reach the wait point.
// Buffering closes that race so the waiter picks up the pending send.
private pendingSends = new Map<string, unknown[]>();
setRunId(_runId: string, _streamsVersion?: string): void {
// No-op — the test driver tracks nothing about runs
}
on(streamId: string, handler: Handler): { off: () => void } {
if (!this.handlers.has(streamId)) {
this.handlers.set(streamId, new Set());
}
this.handlers.get(streamId)!.add(handler);
return {
off: () => {
this.handlers.get(streamId)?.delete(handler);
},
};
}
once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise<unknown> {
return new InputStreamOncePromise<unknown>((resolve) => {
if (options?.signal?.aborted) {
resolve({
ok: false,
error: new InputStreamTimeoutError(streamId, options.timeoutMs ?? 0),
});
return;
}
// Pick up any buffered send that arrived before this waiter.
const buffered = this.pendingSends.get(streamId);
if (buffered && buffered.length > 0) {
const next = buffered.shift();
if (buffered.length === 0) this.pendingSends.delete(streamId);
resolve({ ok: true, output: next });
return;
}
const waiter: OnceWaiter = {
resolve,
signal: options?.signal,
};
if (options?.timeoutMs !== undefined) {
waiter.timer = setTimeout(() => {
this.removeWaiter(streamId, waiter);
resolve({
ok: false,
error: new InputStreamTimeoutError(streamId, options.timeoutMs!),
});
}, options.timeoutMs);
}
if (options?.signal) {
const abortHandler = () => {
this.removeWaiter(streamId, waiter);
if (waiter.timer) clearTimeout(waiter.timer);
resolve({
ok: false,
error: new InputStreamTimeoutError(streamId, options.timeoutMs ?? 0),
});
};
waiter.abortHandler = abortHandler;
options.signal.addEventListener("abort", abortHandler, { once: true });
}
if (!this.onceWaiters.has(streamId)) {
this.onceWaiters.set(streamId, []);
}
this.onceWaiters.get(streamId)!.push(waiter);
});
}
peek(streamId: string): unknown | undefined {
return this.latest.get(streamId);
}
lastSeqNum(streamId: string): number | undefined {
return this.lastSeqNums.get(streamId);
}
setLastSeqNum(streamId: string, seqNum: number): void {
this.lastSeqNums.set(streamId, seqNum);
}
shiftBuffer(_streamId: string): boolean {
return false;
}
disconnectStream(_streamId: string): void {}
clearHandlers(): void {
this.handlers.clear();
}
reset(): void {
// Cancel any pending waiters to avoid dangling promises leaking between tests
for (const waiters of this.onceWaiters.values()) {
for (const w of waiters) {
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
}
}
this.onceWaiters.clear();
this.handlers.clear();
this.latest.clear();
this.lastSeqNums.clear();
this.pendingSends.clear();
}
disconnect(): void {
this.reset();
}
connectTail(_runId: string, _fromSeq?: number): void {}
// ── Test driver API (not part of InputStreamManager interface) ──────────
/**
* Push data onto an input stream. Resolves pending `once()` waiters
* and fires all `on()` handlers (awaiting async handlers).
*/
async __sendFromTest(streamId: string, data: unknown): Promise<void> {
this.latest.set(streamId, data);
const waiters = this.onceWaiters.get(streamId);
const handlers = this.handlers.get(streamId);
const hasWaiters = waiters && waiters.length > 0;
const hasHandlers = handlers && handlers.size > 0;
// If nothing is listening yet, buffer so the next `.once()` call picks it up.
if (!hasWaiters && !hasHandlers) {
if (!this.pendingSends.has(streamId)) {
this.pendingSends.set(streamId, []);
}
this.pendingSends.get(streamId)!.push(data);
return;
}
if (hasWaiters) {
// Drain every pending once() waiter — this mirrors the real manager's
// behavior where the stream tail delivers the same record to all listeners.
const pending = waiters!.splice(0);
for (const w of pending) {
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
w.resolve({ ok: true, output: data });
}
}
if (hasHandlers) {
await Promise.all(
Array.from(handlers!).map((h) => Promise.resolve().then(() => h(data)))
);
}
}
/**
* Immediately resolve every pending `once()` waiter for a stream with a
* timeout error. Used to simulate closed streams (e.g. `exitAfterPreloadIdle`).
*/
__closeFromTest(streamId: string): void {
const waiters = this.onceWaiters.get(streamId);
if (!waiters) return;
const pending = waiters.splice(0);
for (const w of pending) {
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
w.resolve({
ok: false,
error: new InputStreamTimeoutError(streamId, 0),
});
}
}
private removeWaiter(streamId: string, waiter: OnceWaiter): void {
const waiters = this.onceWaiters.get(streamId);
if (!waiters) return;
const idx = waiters.indexOf(waiter);
if (idx >= 0) waiters.splice(idx, 1);
}
}
@@ -0,0 +1,170 @@
import {
AsyncIterableStream,
createAsyncIterableStreamFromAsyncIterable,
} from "../streams/asyncIterableStream.js";
import type {
RealtimeStreamInstance,
RealtimeStreamOperationOptions,
RealtimeStreamsManager,
} from "../realtimeStreams/types.js";
/**
* In-memory implementation of `RealtimeStreamsManager` for unit tests.
* Collects every chunk that tasks write via `pipe()` or `append()` into
* per-stream buffers that tests can inspect.
*
* Use this alongside {@link runInMockTaskContext} not directly.
*/
type WriteListener = (key: string, chunk: unknown) => void;
export class TestRealtimeStreamsManager implements RealtimeStreamsManager {
private buffers = new Map<string, unknown[]>();
private pipeWaits = new Map<string, Promise<void>[]>();
private writeListeners = new Set<WriteListener>();
pipe<T>(
key: string,
source: AsyncIterable<T> | ReadableStream<T>,
_options?: RealtimeStreamOperationOptions
): RealtimeStreamInstance<T> {
const buffer = this.getBuffer(key);
const self = this;
// Eagerly drain the source in the background so chunks land in the
// buffer + notify listeners even when the caller never consumes the
// returned stream. This mirrors the real SDK behavior: `streams.writer`
// awaits `instance.wait()`, it doesn't read the returned stream.
//
// The source is read ONCE (into a chunks array) and replayed into a
// ReadableStream so the caller can still consume it if they want.
const readChunks: T[] = [];
let resolveDone!: () => void;
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
(async () => {
try {
const iter =
source instanceof ReadableStream
? (async function* () {
const reader = source.getReader();
try {
while (true) {
const { done: d, value } = await reader.read();
if (d) return;
yield value as T;
}
} finally {
reader.releaseLock();
}
})()
: source;
for await (const chunk of iter) {
readChunks.push(chunk);
buffer.push(chunk);
self.notify(key, chunk);
}
} catch {
// Swallow — tests can inspect what made it into the buffer
} finally {
resolveDone();
}
})();
const replayStream = (async function* () {
// Wait for all chunks to be drained, then replay from our snapshot
await done;
for (const chunk of readChunks) yield chunk;
})();
const wrappedStream = createAsyncIterableStreamFromAsyncIterable(replayStream);
if (!this.pipeWaits.has(key)) this.pipeWaits.set(key, []);
this.pipeWaits.get(key)!.push(done);
return {
wait: () => done.then(() => ({})),
get stream(): AsyncIterableStream<T> {
return wrappedStream;
},
};
}
async append<TPart extends BodyInit>(
key: string,
part: TPart,
_options?: RealtimeStreamOperationOptions
): Promise<void> {
this.getBuffer(key).push(part);
this.notify(key, part);
}
/**
* Register a listener fired for every chunk written to any stream.
* Returns an unsubscribe function.
*
* Intended for test harnesses that need to react to writes synchronously
* (e.g. resolving a "turn complete" latch).
*/
onWrite(listener: WriteListener): () => void {
this.writeListeners.add(listener);
return () => {
this.writeListeners.delete(listener);
};
}
private notify(key: string, chunk: unknown): void {
for (const listener of this.writeListeners) {
try {
listener(key, chunk);
} catch {
// Never let a listener error break stream writes
}
}
}
// ── Test driver API (not part of RealtimeStreamsManager interface) ──────
/**
* Return all chunks written to the given stream key in order of write.
*/
__chunksFromTest<T = unknown>(key: string): T[] {
return (this.buffers.get(key) ?? []).slice() as T[];
}
/**
* Return all chunks across every stream, keyed by stream id.
*/
__allChunksFromTest(): Record<string, unknown[]> {
const result: Record<string, unknown[]> = {};
for (const [key, chunks] of this.buffers.entries()) {
result[key] = chunks.slice();
}
return result;
}
/**
* Clear the buffer for a specific stream or all streams.
*/
__clearFromTest(key?: string): void {
if (key === undefined) {
this.buffers.clear();
} else {
this.buffers.delete(key);
}
}
reset(): void {
this.buffers.clear();
this.pipeWaits.clear();
this.writeListeners.clear();
}
private getBuffer(key: string): unknown[] {
if (!this.buffers.has(key)) {
this.buffers.set(key, []);
}
return this.buffers.get(key)!;
}
}
@@ -0,0 +1,103 @@
import type { DeserializedJson } from "../../schemas/json.js";
import type { AsyncIterableStream } from "../streams/asyncIterableStream.js";
import type { RunMetadataManager, RunMetadataUpdater } from "../runMetadata/types.js";
/**
* In-memory implementation of `RunMetadataManager` for unit tests.
*
* Just stores metadata in a Map no API calls, no queue. Good enough
* for tests that read/write metadata via `runMetadata.getKey()` /
* `runMetadata.set()`, including the IDLE_TIMEOUT and TURN_TIMEOUT
* checks inside `chat.agent()`.
*/
export class TestRunMetadataManager implements RunMetadataManager {
private store: Record<string, DeserializedJson> = {};
enterWithMetadata(metadata: Record<string, DeserializedJson>): void {
this.store = { ...metadata };
}
current(): Record<string, DeserializedJson> | undefined {
return { ...this.store };
}
getKey(key: string): DeserializedJson | undefined {
return this.store[key];
}
set(key: string, value: DeserializedJson): this {
this.store[key] = value;
return this;
}
del(key: string): this {
delete this.store[key];
return this;
}
append(key: string, value: DeserializedJson): this {
const existing = this.store[key];
if (Array.isArray(existing)) {
existing.push(value);
} else {
this.store[key] = [value];
}
return this;
}
remove(key: string, value: DeserializedJson): this {
const existing = this.store[key];
if (Array.isArray(existing)) {
this.store[key] = existing.filter((v) => v !== value) as DeserializedJson;
}
return this;
}
increment(key: string, value: number): this {
const existing = this.store[key];
const current = typeof existing === "number" ? existing : 0;
this.store[key] = current + value;
return this;
}
decrement(key: string, value: number): this {
return this.increment(key, -value);
}
update(metadata: Record<string, DeserializedJson>): this {
this.store = { ...metadata };
return this;
}
async flush(): Promise<void> {}
async refresh(): Promise<void> {}
async stream<T>(
_key: string,
value: AsyncIterable<T> | ReadableStream<T>
): Promise<AsyncIterable<T>> {
return value as AsyncIterable<T>;
}
async fetchStream<T>(_key: string): Promise<AsyncIterableStream<T>> {
// Return an empty async iterable — tests can override if needed
const empty = {
[Symbol.asyncIterator]: () => ({
next: () => Promise.resolve({ done: true as const, value: undefined as T }),
}),
};
return empty as unknown as AsyncIterableStream<T>;
}
get parent(): RunMetadataUpdater {
return this;
}
get root(): RunMetadataUpdater {
return this;
}
reset(): void {
this.store = {};
}
}
@@ -0,0 +1,286 @@
import {
InputStreamOncePromise,
InputStreamOnceResult,
InputStreamTimeoutError,
} from "../inputStreams/types.js";
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
import type {
SessionChannelIO,
SessionStreamManager,
} from "../sessionStreams/types.js";
type OnceWaiter = {
resolve: (value: InputStreamOnceResult<unknown>) => void;
timer?: ReturnType<typeof setTimeout>;
signal?: AbortSignal;
abortHandler?: () => void;
};
type Handler = (data: unknown) => void | Promise<void>;
function keyFor(sessionId: string, io: SessionChannelIO): string {
return `${sessionId}:${io}`;
}
/**
* In-memory implementation of `SessionStreamManager` for unit tests. Same
* shape as {@link TestInputStreamManager} but keyed on `(sessionId, io)`.
*
* Tests push data via `__sendFromTest(sessionId, io, data)` any pending
* `once()` waiters resolve immediately, and all `on()` handlers fire (awaited
* if they return a promise). Records that arrive before a listener is
* registered are buffered so the first `once()` picks them up.
*/
export class TestSessionStreamManager implements SessionStreamManager {
private handlers = new Map<string, Set<Handler>>();
private onceWaiters = new Map<string, OnceWaiter[]>();
private buffer = new Map<string, unknown[]>();
private seqNums = new Map<string, number>();
on(
sessionId: string,
io: SessionChannelIO,
handler: Handler
): { off: () => void } {
const key = keyFor(sessionId, io);
let set = this.handlers.get(key);
if (!set) {
set = new Set();
this.handlers.set(key, set);
}
set.add(handler);
// Note: we intentionally do NOT replay buffered records into the
// newly-registered handler, and we do NOT drain the buffer. The
// buffer is owned by `once()` — registering a passive observer
// (`on`) must not consume records destined for a future `once`
// waiter. This matches production SSE semantics where handlers
// observe records as they arrive, not retroactively.
//
// Earlier versions drained the buffer here, which caused user
// messages buffered during the runtime's `runFn` boot phase to be
// silently swallowed by the `stopInput.on()` handler registered at
// ai.ts:4806 (the stop handler ignores `kind: "message"` chunks).
// The next `messagesInput.waitWithIdleTimeout` then waited 30s for
// a record that had already been "delivered" to a handler that
// didn't want it.
return {
off: () => {
this.handlers.get(key)?.delete(handler);
},
};
}
once(
sessionId: string,
io: SessionChannelIO,
options?: InputStreamOnceOptions
): InputStreamOncePromise<unknown> {
const key = keyFor(sessionId, io);
return new InputStreamOncePromise<unknown>((resolve) => {
if (options?.signal?.aborted) {
resolve({
ok: false,
error: new InputStreamTimeoutError(key, options.timeoutMs ?? 0),
});
return;
}
const buffered = this.buffer.get(key);
if (buffered && buffered.length > 0) {
const next = buffered.shift();
if (buffered.length === 0) this.buffer.delete(key);
resolve({ ok: true, output: next });
return;
}
const waiter: OnceWaiter = { resolve, signal: options?.signal };
if (options?.timeoutMs !== undefined) {
waiter.timer = setTimeout(() => {
this.removeWaiter(key, waiter);
resolve({
ok: false,
error: new InputStreamTimeoutError(key, options.timeoutMs!),
});
}, options.timeoutMs);
}
if (options?.signal) {
const abortHandler = () => {
this.removeWaiter(key, waiter);
if (waiter.timer) clearTimeout(waiter.timer);
resolve({
ok: false,
error: new InputStreamTimeoutError(key, options.timeoutMs ?? 0),
});
};
waiter.abortHandler = abortHandler;
options.signal.addEventListener("abort", abortHandler, { once: true });
}
let waiters = this.onceWaiters.get(key);
if (!waiters) {
waiters = [];
this.onceWaiters.set(key, waiters);
}
waiters.push(waiter);
});
}
peek(sessionId: string, io: SessionChannelIO): unknown | undefined {
const buffered = this.buffer.get(keyFor(sessionId, io));
if (buffered && buffered.length > 0) return buffered[0];
return undefined;
}
lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined {
return this.seqNums.get(keyFor(sessionId, io));
}
setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void {
this.seqNums.set(keyFor(sessionId, io), seqNum);
}
setMinTimestamp(
_sessionId: string,
_io: SessionChannelIO,
_minTimestamp: number | undefined
): void {
// No filter applied in tests; the test harness drives records directly
// and the chat.agent retry path is exercised separately.
}
shiftBuffer(sessionId: string, io: SessionChannelIO): boolean {
const key = keyFor(sessionId, io);
const buffered = this.buffer.get(key);
if (buffered && buffered.length > 0) {
buffered.shift();
if (buffered.length === 0) this.buffer.delete(key);
return true;
}
return false;
}
disconnectStream(_sessionId: string, _io: SessionChannelIO): void {
// no-op — no real SSE tail in tests
}
clearHandlers(): void {
this.handlers.clear();
}
reset(): void {
for (const waiters of this.onceWaiters.values()) {
for (const w of waiters) {
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
}
}
this.onceWaiters.clear();
this.handlers.clear();
this.buffer.clear();
this.seqNums.clear();
}
disconnect(): void {
this.reset();
}
// ── Test driver API (not part of SessionStreamManager interface) ──────
/**
* Push a record onto the given channel.
*
* Dispatch rules similar to the production manager, but with a tweak
* that makes unit tests deterministic:
*
* 1. **Handlers always observe** (like production). A session-level `.on`
* is a filter-observer it fires every time a record arrives,
* regardless of whether a `.once` waiter is also active.
* 2. **First waiter consumes** the record if present (like production).
* 3. **If no waiter, the record is buffered for the next `.once` call.**
* Production discards records that only match handlers but in
* production the SSE tail introduces enough latency that the next
* `.once` is usually registered before the next record arrives. Tests
* send synchronously right after `turn-complete`, so without this
* buffer the next `waitWithIdleTimeout` would race and lose the
* message. The buffer is the only deviation from production semantics.
*/
async __sendFromTest(
sessionId: string,
io: SessionChannelIO,
data: unknown
): Promise<void> {
const key = keyFor(sessionId, io);
const handlers = this.handlers.get(key);
if (handlers && handlers.size > 0) {
// Awaited so test code can rely on handlers having completed by the
// time `__sendFromTest` resolves. Wrapped per-handler so a
// throwing/rejecting handler doesn't poison Promise.all and break
// unrelated test state.
await Promise.all(
Array.from(handlers).map(async (h) => {
try {
await h(data);
} catch {
// Never let a handler error break test state
}
})
);
}
const waiters = this.onceWaiters.get(key);
if (waiters && waiters.length > 0) {
const w = waiters.shift()!;
if (waiters.length === 0) this.onceWaiters.delete(key);
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
w.resolve({ ok: true, output: data });
return;
}
let buffered = this.buffer.get(key);
if (!buffered) {
buffered = [];
this.buffer.set(key, buffered);
}
buffered.push(data);
}
/**
* Immediately resolve every pending `once()` waiter for the given channel
* with a timeout error. Simulates a closed stream (e.g. session closed).
*/
__closeFromTest(sessionId: string, io: SessionChannelIO): void {
const key = keyFor(sessionId, io);
const waiters = this.onceWaiters.get(key);
if (!waiters) return;
const pending = waiters.splice(0);
for (const w of pending) {
if (w.timer) clearTimeout(w.timer);
if (w.signal && w.abortHandler) {
w.signal.removeEventListener("abort", w.abortHandler);
}
w.resolve({
ok: false,
error: new InputStreamTimeoutError(key, 0),
});
}
}
private removeWaiter(key: string, waiter: OnceWaiter): void {
const waiters = this.onceWaiters.get(key);
if (!waiters) return;
const idx = waiters.indexOf(waiter);
if (idx >= 0) waiters.splice(idx, 1);
}
}
+40
View File
@@ -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<
@@ -641,6 +647,30 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
requestOptions?: TriggerApiRequestOptions
) => TaskRunPromise<TIdentifier, TOutput>;
/**
* Trigger a task and subscribe to its updates via realtime. Unlike `triggerAndWait`,
* this does NOT suspend the parent run the parent stays alive and polls for updates.
* This enables parallel tool calls and proper abort signal handling.
*
* @param payload
* @param options - Options for the task run, including an optional `signal` to cancel the subscription and child run
* @returns TaskRunPromise
* @example
* ```
* const result = await task.triggerAndSubscribe({ foo: "bar" }, { signal: abortSignal });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndSubscribe: (
payload: TInput,
options?: TriggerAndSubscribeOptions,
) => TaskRunPromise<TIdentifier, TOutput>;
/**
* Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs.
* @param items - Array, AsyncIterable, or ReadableStream of batch items
@@ -989,6 +1019,16 @@ export type TriggerOptions = {
};
export type TriggerAndWaitOptions = Omit<TriggerOptions, "version">;
export type TriggerAndSubscribeOptions = Omit<TriggerOptions, "version"> & {
/** An AbortSignal to cancel the subscription. When fired, the subscription closes and the promise rejects. */
signal?: AbortSignal;
/**
* Whether to cancel the child run when the abort signal fires.
* @default true
*/
cancelOnAbort?: boolean;
};
export type BatchTriggerOptions = {
/**
* If no idempotencyKey is set on an individual item in the batch, it will use this key on each item + the array index.
+2
View File
@@ -3,6 +3,7 @@ import { Clock } from "../clock/clock.js";
import { HeartbeatsManager } from "../heartbeats/types.js";
import type { IdempotencyKeyCatalog } from "../idempotency-key-catalog/catalog.js";
import { InputStreamManager } from "../inputStreams/types.js";
import { SessionStreamManager } from "../sessionStreams/types.js";
import { LifecycleHooksManager } from "../lifecycleHooks/types.js";
import { LocalsManager } from "../locals/types.js";
import { RealtimeStreamsManager } from "../realtimeStreams/types.js";
@@ -76,4 +77,5 @@ type TriggerDotDevGlobalAPI = {
["heartbeats"]?: HeartbeatsManager;
["realtime-streams"]?: RealtimeStreamsManager;
["input-streams"]?: InputStreamManager;
["session-streams"]?: SessionStreamManager;
};
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RECONNECT_BACKOFF_MAX_MS, computeReconnectDelayMs } from "./reconnectBackoff.js";
afterEach(() => {
vi.restoreAllMocks();
});
describe("computeReconnectDelayMs", () => {
// Hold Math.random steady so we can assert on the deterministic base. The
// jitter is added separately in the "jitter" test below.
function withFixedRandom(value: number, fn: () => void) {
const spy = vi.spyOn(Math, "random").mockReturnValue(value);
try {
fn();
} finally {
spy.mockRestore();
}
}
it("base case — attempt 0 lands in [1000, 2000)", () => {
withFixedRandom(0, () => {
expect(computeReconnectDelayMs(0)).toBe(1000);
});
withFixedRandom(0.999, () => {
expect(computeReconnectDelayMs(0)).toBeGreaterThanOrEqual(1000);
expect(computeReconnectDelayMs(0)).toBeLessThan(2000);
});
});
it("doubles per attempt up to the 30s cap", () => {
withFixedRandom(0, () => {
// 1s, 2s, 4s, 8s, 16s, then capped at 30s
expect(computeReconnectDelayMs(0)).toBe(1_000);
expect(computeReconnectDelayMs(1)).toBe(2_000);
expect(computeReconnectDelayMs(2)).toBe(4_000);
expect(computeReconnectDelayMs(3)).toBe(8_000);
expect(computeReconnectDelayMs(4)).toBe(16_000);
// 32s would exceed the cap — should clamp to 30s.
expect(computeReconnectDelayMs(5)).toBe(RECONNECT_BACKOFF_MAX_MS);
// High attempt counts stay capped — protects against integer
// overflow on 2 ** N for large N.
expect(computeReconnectDelayMs(50)).toBe(RECONNECT_BACKOFF_MAX_MS);
expect(computeReconnectDelayMs(1_000)).toBe(RECONNECT_BACKOFF_MAX_MS);
});
});
it("never exceeds RECONNECT_BACKOFF_MAX_MS + 1000ms (cap + jitter ceiling)", () => {
withFixedRandom(0.999, () => {
for (let attempt = 0; attempt < 100; attempt++) {
expect(computeReconnectDelayMs(attempt)).toBeLessThan(
RECONNECT_BACKOFF_MAX_MS + 1000
);
}
});
});
it("adds 01000ms of jitter on top of the base", () => {
// Compare same attempt with random=0 vs random=0.5 — the difference is
// exactly the jitter.
withFixedRandom(0, () => {
expect(computeReconnectDelayMs(2)).toBe(4_000);
});
withFixedRandom(0.5, () => {
expect(computeReconnectDelayMs(2)).toBe(4_500);
});
withFixedRandom(0.999, () => {
const v = computeReconnectDelayMs(2);
expect(v).toBeGreaterThan(4_000);
expect(v).toBeLessThan(5_000);
});
});
it("clamps negative / non-integer attempts to 0 (no NaN, no negative delay)", () => {
withFixedRandom(0, () => {
expect(computeReconnectDelayMs(-1)).toBe(1_000);
expect(computeReconnectDelayMs(-100)).toBe(1_000);
expect(computeReconnectDelayMs(0.7)).toBe(1_000); // floored to 0
expect(computeReconnectDelayMs(2.9)).toBe(4_000); // floored to 2
});
});
});
@@ -0,0 +1,25 @@
/**
* Exponential backoff with full jitter for stream-tail reconnect loops.
*
* Shared between `SessionStreamManager` and `StandardInputStreamManager`
* both reconnect a long-lived SSE tail when handlers/waiters are still
* registered, and both need to back off on persistent backend failures
* (auth rejection, 5xx, DNS) instead of reconnecting in a tight loop.
*
* - Base 1s, doubles per attempt (1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
* - Capped at 30s
* - Plus 01000ms jitter to avoid thundering herd when many clients
* share the same failure mode
* - Negative or non-integer attempts are clamped to 0
*
* Callers track the per-key attempt count and reset to 0 on every
* successful record (any traffic flowing = healthy connection).
*/
export function computeReconnectDelayMs(attempt: number): number {
const safeAttempt = Math.max(0, Math.floor(attempt));
const base = Math.min(1000 * 2 ** safeAttempt, 30_000);
return base + Math.random() * 1000;
}
/** Maximum backoff floor without jitter — exposed for tests / asserts. */
export const RECONNECT_BACKOFF_MAX_MS = 30_000;
+1
View File
@@ -33,3 +33,4 @@ export { StandardTraceContextManager } from "../traceContext/manager.js";
export { StandardHeartbeatsManager } from "../heartbeats/manager.js";
export { StandardRealtimeStreamsManager } from "../realtimeStreams/manager.js";
export { StandardInputStreamManager } from "../inputStreams/manager.js";
export { StandardSessionStreamManager } from "../sessionStreams/manager.js";
@@ -17,6 +17,7 @@ import {
lifecycleHooks,
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT,
runMetadata,
sessionStreams,
traceContext,
waitUntil,
} from "../index.js";
@@ -1048,6 +1049,7 @@ export class TaskExecutor {
) {
await this.#callCleanupFunctions(payload, ctx, initOutput, signal);
inputStreams.clearHandlers();
sessionStreams.clearHandlers();
await this.#blockForWaitUntil();
}
+43 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RunSubscription,
SSEStreamPart,
SSEStreamSubscription,
StreamSubscription,
StreamSubscriptionFactory,
} from "../src/v3/apiClient/runStream.js";
@@ -470,6 +471,47 @@ describe("RunSubscription", () => {
});
});
describe("SSEStreamSubscription", () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("does not retry the initial fetch on 401", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }));
global.fetch = fetchMock;
const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", {
headers: { Authorization: "Bearer expired" },
});
const stream = await sub.subscribe();
const reader = stream.getReader();
await expect(reader.read()).rejects.toMatchObject({ status: 401 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not retry the initial fetch on 403", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
global.fetch = fetchMock;
const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", {
headers: { Authorization: "Bearer denied" },
});
const stream = await sub.subscribe();
const reader = stream.getReader();
await expect(reader.read()).rejects.toMatchObject({ status: 403 });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
export async function convertAsyncIterableToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const result: T[] = [];
for await (const item of iterable) {
+238 -1
View File
@@ -90,6 +90,7 @@ import type {
TaskWithToolOptions,
ToolTask,
ToolTaskParameters,
TriggerAndSubscribeOptions,
TriggerAndWaitOptions,
TriggerApiRequestOptions,
TriggerOptions,
@@ -214,6 +215,26 @@ export function createTask<
});
}, params.id);
},
triggerAndSubscribe: (payload, options) => {
return new TaskRunPromise<TIdentifier, TOutput>((resolve, reject) => {
triggerAndSubscribe_internal<TIdentifier, TInput, TOutput>(
"triggerAndSubscribe()",
params.id,
payload,
undefined,
{
queue: params.queue?.name,
...options,
}
)
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
}, params.id);
},
batchTriggerAndWait: async (items, options) => {
return await batchTriggerAndWait_internal<TIdentifier, TInput, TOutput>(
"batchTriggerAndWait()",
@@ -235,6 +256,8 @@ export function createTask<
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,
@@ -259,7 +282,7 @@ export function createTask<
}
/**
* @deprecated use ai.tool() instead
* @deprecated Use `schemaTask` plus AI SDK `tool()` with `execute: ai.toolExecute(task)` instead.
*/
export function createToolTask<
TIdentifier extends string,
@@ -346,6 +369,26 @@ export function createSchemaTask<
});
}, params.id);
},
triggerAndSubscribe: (payload, options) => {
return new TaskRunPromise<TIdentifier, TOutput>((resolve, reject) => {
triggerAndSubscribe_internal<TIdentifier, inferSchemaIn<TSchema>, TOutput>(
"triggerAndSubscribe()",
params.id,
payload,
parsePayload,
{
queue: params.queue?.name,
...options,
}
)
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
}, params.id);
},
batchTriggerAndWait: async (items, options) => {
return await batchTriggerAndWait_internal<TIdentifier, inferSchemaIn<TSchema>, TOutput>(
"batchTriggerAndWait()",
@@ -367,6 +410,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: {
@@ -465,6 +510,51 @@ export function triggerAndWait<TTask extends AnyTask>(
}, id);
}
/**
* Trigger a task and subscribe to its updates via realtime. Unlike `triggerAndWait`,
* this does NOT suspend the parent run the parent stays alive and subscribes to updates.
* This enables parallel execution and proper abort signal handling.
*
* @param id - The id of the task to trigger
* @param payload
* @param options - Options for the task run, including an optional `signal` to cancel the subscription and child run
* @returns TaskRunPromise
* @example
* ```ts
* import { tasks } from "@trigger.dev/sdk/v3";
* const result = await tasks.triggerAndSubscribe("my-task", { foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
export function triggerAndSubscribe<TTask extends AnyTask>(
id: TaskIdentifier<TTask>,
payload: TaskPayload<TTask>,
options?: TriggerAndSubscribeOptions,
requestOptions?: TriggerApiRequestOptions
): TaskRunPromise<TaskIdentifier<TTask>, TaskOutput<TTask>> {
return new TaskRunPromise<TaskIdentifier<TTask>, TaskOutput<TTask>>((resolve, reject) => {
triggerAndSubscribe_internal<TaskIdentifier<TTask>, TaskPayload<TTask>, TaskOutput<TTask>>(
"tasks.triggerAndSubscribe()",
id,
payload,
undefined,
options,
requestOptions
)
.then((result) => {
resolve(result);
})
.catch((error) => {
reject(error);
});
}, id);
}
/**
* Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs.
* @param id - The id of the task to trigger
@@ -2441,6 +2531,153 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
);
}
async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload, TOutput>(
name: string,
id: TIdentifier,
payload: TPayload,
parsePayload?: SchemaParseFn<TPayload>,
options?: TriggerAndSubscribeOptions,
requestOptions?: TriggerApiRequestOptions
): Promise<TaskRunResult<TIdentifier, TOutput>> {
const ctx = taskContext.ctx;
if (!ctx) {
throw new Error("triggerAndSubscribe can only be used from inside a task.run()");
}
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
const payloadPacket = await stringifyIO(parsedPayload);
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
const idempotencyKeyOptions = processedIdempotencyKey
? getIdempotencyKeyOptions(processedIdempotencyKey)
: undefined;
return await tracer.startActiveSpan(
name,
async (span) => {
const response = await apiClient.triggerTask(
id,
{
payload: payloadPacket.data,
options: {
lockToVersion: taskContext.worker?.version,
queue: options?.queue ? { name: options.queue } : undefined,
concurrencyKey: options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: payloadPacket.dataType,
delay: options?.delay,
ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts,
metadata: options?.metadata,
maxDuration: options?.maxDuration,
parentRunId: ctx.run.id,
// NOTE: no resumeParentOnCompletion — parent stays alive and subscribes
idempotencyKey: processedIdempotencyKey?.toString(),
idempotencyKeyTTL: options?.idempotencyKeyTTL,
idempotencyKeyOptions,
machine: options?.machine,
priority: options?.priority,
region: options?.region,
debounce: options?.debounce,
},
},
{}
);
// Set attributes after trigger so the dashboard can link to the child run
span.setAttribute("messaging.message.id", response.id);
span.setAttribute("runId", response.id);
span.setAttribute(SemanticInternalAttributes.ENTITY_TYPE, "run");
span.setAttribute(SemanticInternalAttributes.ENTITY_ID, response.id);
// Optionally cancel the child run when the abort signal fires (default: true)
const cancelOnAbort = options?.cancelOnAbort !== false;
let onAbort: (() => void) | undefined;
if (options?.signal && cancelOnAbort) {
if (options.signal.aborted) {
await apiClient.cancelRun(response.id).catch(() => {});
throw new DOMException("Aborted", "AbortError");
}
onAbort = () => {
apiClient.cancelRun(response.id).catch(() => {});
};
// `{ once: true }` auto-removes the listener on abort, but if the
// run completes normally the listener stays attached and pins
// `apiClient` + `response.id` until the signal is GC'd. Long-lived
// signals shared across many calls accumulate dead listeners; the
// `finally` below removes the listener on every exit path.
options.signal.addEventListener("abort", onAbort, { once: true });
}
try {
for await (const run of apiClient.subscribeToRun(response.id, {
closeOnComplete: true,
signal: options?.signal,
skipColumns: ["payload"],
})) {
if (run.isSuccess) {
// run.output from subscribeToRun is already deserialized
return {
ok: true as const,
id: response.id,
taskIdentifier: id as TIdentifier,
output: run.output as TOutput,
};
}
if (run.isFailed || run.isCancelled) {
// Note: this intentionally diverges from `triggerAndWait`'s
// error shape. `triggerAndWait` receives a full `TaskRunError`
// (with `type` discriminator: BUILT_IN_ERROR, CUSTOM_ERROR,
// INTERNAL_ERROR, STRING_ERROR) via the completion message and
// passes it through `createErrorTaskError` to preserve the
// discriminator. `subscribeToRun` only surfaces a
// `SerializedError` (`{ name, message, stackTrace }`) because
// `createJsonErrorObject` strips the discriminator before the
// record hits the realtime stream. We can't reconstruct the
// discriminator here without lossy guessing — callers that
// need exact error-type matching should use `triggerAndWait`
// instead; subscribers get message + name only.
const error = new Error(run.error?.message ?? `Task ${id} failed (${run.status})`);
if (run.error?.name) error.name = run.error.name;
return {
ok: false as const,
id: response.id,
taskIdentifier: id as TIdentifier,
error,
};
}
}
throw new Error(`Task ${id}: subscription ended without completion`);
} finally {
if (onAbort && options?.signal) {
options.signal.removeEventListener("abort", onAbort);
}
}
},
{
kind: SpanKind.PRODUCER,
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "trigger",
...accessoryAttributes({
items: [
{
text: id,
variant: "normal",
},
],
style: "codepath",
}),
},
}
);
}
async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload, TOutput>(
name: string,
id: TIdentifier,
+110 -20
View File
@@ -25,8 +25,10 @@ import {
InputStreamOncePromise,
type InputStreamOnceResult,
type InputStreamWaitOptions,
type InputStreamWaitWithIdleTimeoutOptions,
type SendInputStreamOptions,
type InferInputStreamType,
type StreamWriteResult,
} from "@trigger.dev/core/v3";
import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization";
import { tracer } from "./tracer.js";
@@ -139,7 +141,7 @@ function pipe<T>(
opts = valueOrOptions as PipeStreamOptions | undefined;
}
return pipeInternal(key, value, opts, "streams.pipe()");
return pipeInternal(key, value, opts, opts?.spanName ?? "streams.pipe()");
}
/**
@@ -167,6 +169,7 @@ function pipeInternal<T>(
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
[SemanticInternalAttributes.STYLE_ICON]: "streams",
...(opts?.collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}),
...accessoryAttributes({
items: [
{
@@ -194,7 +197,9 @@ function pipeInternal<T>(
return {
stream: instance.stream,
waitUntilComplete: () => instance.wait(),
waitUntilComplete: async () => {
return instance.wait();
},
};
} catch (error) {
// if the error is a signal abort error, we need to end the span but not record an exception
@@ -640,7 +645,7 @@ function writerInternal<TPart>(key: string, options: WriterStreamOptions<TPart>)
}
});
return pipeInternal(key, stream, options, "streams.writer()");
return pipeInternal(key, stream, options, options.spanName ?? "streams.writer()");
}
export type RealtimeDefineStreamOptions = {
@@ -656,8 +661,18 @@ function define<TPart>(opts: RealtimeDefineStreamOptions): RealtimeDefinedStream
read(runId, options) {
return read(runId, opts.id, options);
},
append(value, options) {
return append(opts.id, value as BodyInit, options);
async append(value, options) {
// Use a single-write writer so objects are serialized the same way
// as stream.writer() — the raw append API sends BodyInit which
// doesn't serialize objects correctly for SSE consumers.
const { waitUntilComplete } = writer(opts.id, {
...options,
spanName: "streams.append()",
execute: ({ write }) => {
write(value);
},
});
await waitUntilComplete();
},
writer(options) {
return writer(opts.id, options);
@@ -713,7 +728,7 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
return new InputStreamOncePromise<TData>((resolve, reject) => {
tracer
.startActiveSpan(
`inputStream.once()`,
options?.spanName ?? `inputStream.once()`,
async () => {
const result = await innerPromise;
resolve(result as InputStreamOnceResult<TData>);
@@ -750,23 +765,21 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
const apiClient = apiClientManager.clientOrThrow();
// Create the waitpoint before the span so we have the entity ID upfront
const response = await apiClient.createInputStreamWaitpoint(ctx.run.id, {
streamId: opts.id,
timeout: options?.timeout,
idempotencyKey: options?.idempotencyKey,
idempotencyKeyTTL: options?.idempotencyKeyTTL,
tags: options?.tags,
lastSeqNum: inputStreams.lastSeqNum(opts.id),
});
const result = await tracer.startActiveSpan(
`inputStream.wait()`,
options?.spanName ?? `inputStream.wait()`,
async (span) => {
// 1. Create a waitpoint linked to this input stream
const response = await apiClient.createInputStreamWaitpoint(ctx.run.id, {
streamId: opts.id,
timeout: options?.timeout,
idempotencyKey: options?.idempotencyKey,
idempotencyKeyTTL: options?.idempotencyKeyTTL,
tags: options?.tags,
lastSeqNum: inputStreams.lastSeqNum(opts.id),
});
// Set the entity ID now that we have the waitpoint ID
span.setAttribute(SemanticInternalAttributes.ENTITY_ID, response.waitpointId);
// 2. Block the run on the waitpoint
// 1. Block the run on the waitpoint
const waitResponse = await apiClient.waitForWaitpointToken({
runFriendlyId: ctx.run.id,
waitpointFriendlyId: response.waitpointId,
@@ -776,6 +789,12 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
throw new Error("Failed to block on input stream waitpoint");
}
// 2. Disconnect the SSE tail and clear the buffer before suspending.
// Without this, the tail stays alive during the suspension window and
// may buffer a copy of the same message that will be delivered via the
// waitpoint, causing a duplicate on resume.
inputStreams.disconnectStream(opts.id);
// 3. Suspend the task
const waitResult = await runtime.waitUntil(response.waitpointId);
@@ -792,6 +811,12 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
: undefined;
if (waitResult.ok) {
// Advance the seq counter so the SSE tail doesn't replay
// the record that was consumed via the waitpoint path when
// it lazily reconnects on the next on()/once() call.
const prevSeq = inputStreams.lastSeqNum(opts.id);
inputStreams.setLastSeqNum(opts.id, (prevSeq ?? -1) + 1);
return { ok: true as const, output: data as TData };
} else {
const error = new WaitpointTimeoutError(data?.message ?? "Timed out");
@@ -806,6 +831,7 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "wait",
[SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint",
[SemanticInternalAttributes.ENTITY_ID]: response.waitpointId,
streamId: opts.id,
...accessoryAttributes({
items: [
@@ -826,6 +852,70 @@ function input<TData>(opts: { id: string }): RealtimeDefinedInputStream<TData> {
}
});
},
async waitWithIdleTimeout(options) {
const self = this;
const spanName = options.spanName ?? `inputStream.waitWithIdleTimeout()`;
return tracer.startActiveSpan(
spanName,
async (span) => {
// Idle phase: keep compute alive
if (options.idleTimeoutInSeconds > 0) {
const warm = await inputStreams.once(opts.id, {
timeoutMs: options.idleTimeoutInSeconds * 1000,
});
if (warm.ok) {
span.setAttribute("wait.resolved", "idle");
return { ok: true as const, output: warm.output as TData };
}
}
// Skip suspend if requested — return a real WaitpointTimeoutError
// so the result shape matches the cold-phase `self.wait()` path
// below. Callers that check `if (!result.ok)` work the same as
// before; callers that do `throw result.error` get a useful error
// instead of `undefined`.
if (options.skipSuspend) {
span.setAttribute("wait.resolved", "skipped");
return {
ok: false as const,
error: new WaitpointTimeoutError(
"Idle timeout elapsed and skipSuspend is set"
),
};
}
// Fire onSuspend callback before entering cold phase
if (options.onSuspend) {
await options.onSuspend();
}
// Cold phase: suspend via .wait() — creates a child span
span.setAttribute("wait.resolved", "suspended");
const waitResult = await self.wait({
timeout: options.timeout,
spanName: "suspended",
});
// Fire onResume callback after successful resume
if (waitResult.ok && options.onResume) {
await options.onResume();
}
return waitResult;
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "streams",
streamId: opts.id,
...accessoryAttributes({
items: [{ text: opts.id, variant: "normal" }],
style: "codepath",
}),
},
}
);
},
async send(runId, data, options) {
return tracer.startActiveSpan(
`inputStream.send()`,
+2
View File
@@ -20,6 +20,7 @@ import {
SubtaskUnwrapError,
trigger,
triggerAndWait,
triggerAndSubscribe,
} from "./shared.js";
export { SubtaskUnwrapError };
@@ -96,6 +97,7 @@ export const tasks = {
trigger,
batchTrigger,
triggerAndWait,
triggerAndSubscribe,
batchTriggerAndWait,
/** @deprecated Use onStartAttempt instead */
onStart,