Skip to content

Commit b84d537

Browse files
committed
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.
1 parent 6cdd881 commit b84d537

88 files changed

Lines changed: 6048 additions & 162 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/sessions-primitive.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Add Sessions — a durable, task-bound, bidirectional channel pair that outlives any single run. One identifier (your `externalId`), many runs over time, with a stable `.in` channel clients can write to and a stable `.out` channel they can subscribe to. Powers `chat.agent` (separate changeset), and unblocks anything that needs "resume tomorrow" or "approval loop" workflows.
7+
8+
```ts
9+
const session = await sessions.create({ externalId: chatId, taskIdentifier: "my-task" });
10+
await session.in.send({ kind: "message", payload: "..." });
11+
for await (const chunk of session.out.read()) { /* ... */ }
12+
```
13+
14+
Inside the task, `.in.wait()` / `.waitWithIdleTimeout()` suspends the run on a session-stream waitpoint until the next record arrives. `.out.append` / `.pipe` / `.writer` produce records via direct-to-S2 writes.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ apps/**/public/build
6565
/packages/trigger-sdk/src/package.json
6666
/packages/python/src/package.json
6767
**/.claude/settings.local.json
68+
.claude/architecture/
69+
.claude/docs-plans/
70+
.claude/review-guides/
71+
.claude/scheduled_tasks.lock
6872
.mcp.log
6973
.mcp.json
7074
.cursor/debug.log

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. Su
66

77
This is a pnpm 10.33.2 monorepo using Turborepo. Run commands from root with `pnpm run`.
88

9+
**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.
10+
911
```bash
1012
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
1113
pnpm run db:migrate # Run database migrations

apps/webapp/app/components/BulkActionFilterSummary.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,19 @@ export function BulkActionFilterSummary({
240240
/>
241241
);
242242
}
243+
case "sources": {
244+
const values = Array.isArray(value) ? value : [`${value}`];
245+
return (
246+
<AppliedFilter
247+
variant="minimal/medium"
248+
key={key}
249+
label={filterTitle(key)}
250+
icon={filterIcon(key)}
251+
value={appliedSummary(values)}
252+
removable={false}
253+
/>
254+
);
255+
}
243256
default: {
244257
assertNever(typedKey);
245258
}

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react";
22
import {
33
CalendarIcon,
44
ClockIcon,
5+
CpuChipIcon,
56
FingerPrintIcon,
67
PlusIcon,
78
RectangleStackIcon,
@@ -190,6 +191,9 @@ export const TaskRunListSearchFilters = z.object({
190191
`Machine presets to filter by (${machines.join(", ")})`
191192
),
192193
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
194+
sources: StringOrStringArray.describe(
195+
"Task trigger sources to filter by (STANDARD, SCHEDULED, AGENT)"
196+
),
193197
});
194198

195199
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -231,6 +235,8 @@ export function filterTitle(filterKey: string) {
231235
return "Version";
232236
case "errorId":
233237
return "Error ID";
238+
case "sources":
239+
return "Source";
234240
default:
235241
return filterKey;
236242
}
@@ -271,6 +277,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
271277
return <IconRotateClockwise2 className="size-4" />;
272278
case "errorId":
273279
return <IconBugFilled className="size-4" />;
280+
case "sources":
281+
return <CpuChipIcon className="size-4" />;
274282
default:
275283
return undefined;
276284
}
@@ -318,6 +326,10 @@ export function getRunFiltersFromSearchParams(
318326
? searchParams.getAll("versions")
319327
: undefined,
320328
errorId: searchParams.get("errorId") ?? undefined,
329+
sources:
330+
searchParams.getAll("sources").filter((v) => v.length > 0).length > 0
331+
? searchParams.getAll("sources")
332+
: undefined,
321333
};
322334

323335
const parsed = TaskRunListSearchFilters.safeParse(params);
@@ -359,7 +371,8 @@ export function RunsFilters(props: RunFiltersProps) {
359371
searchParams.has("queues") ||
360372
searchParams.has("machines") ||
361373
searchParams.has("versions") ||
362-
searchParams.has("errorId");
374+
searchParams.has("errorId") ||
375+
searchParams.has("sources");
363376

364377
return (
365378
<div className="flex flex-row flex-wrap items-center gap-1.5">
@@ -395,6 +408,7 @@ const filterTypes = [
395408
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
396409
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
397410
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
411+
{ name: "source", title: "Source", icon: <CpuChipIcon className="size-4" /> },
398412
] as const;
399413

400414
type FilterType = (typeof filterTypes)[number]["name"];
@@ -448,6 +462,7 @@ function AppliedFilters({ bulkActions }: RunFiltersProps) {
448462
<AppliedScheduleIdFilter />
449463
<AppliedBulkActionsFilter bulkActions={bulkActions} />
450464
<AppliedErrorIdFilter />
465+
<AppliedSourceFilter />
451466
</>
452467
);
453468
}
@@ -482,6 +497,8 @@ function Menu(props: MenuProps) {
482497
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
483498
case "error":
484499
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
500+
case "source":
501+
return <SourceDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
485502
}
486503
}
487504

@@ -1739,3 +1756,101 @@ function AppliedErrorIdFilter() {
17391756
</FilterMenuProvider>
17401757
);
17411758
}
1759+
1760+
const sourceOptions: { value: TaskTriggerSource; title: string }[] = [
1761+
{ value: "STANDARD", title: "Standard" },
1762+
{ value: "SCHEDULED", title: "Scheduled" },
1763+
{ value: "AGENT", title: "Agent" },
1764+
];
1765+
1766+
function SourceDropdown({
1767+
trigger,
1768+
clearSearchValue,
1769+
searchValue,
1770+
onClose,
1771+
}: {
1772+
trigger: ReactNode;
1773+
clearSearchValue: () => void;
1774+
searchValue: string;
1775+
onClose?: () => void;
1776+
}) {
1777+
const { values, replace } = useSearchParams();
1778+
1779+
const handleChange = (values: string[]) => {
1780+
clearSearchValue();
1781+
replace({ sources: values, cursor: undefined, direction: undefined });
1782+
};
1783+
1784+
const filtered = useMemo(() => {
1785+
return sourceOptions.filter((item) =>
1786+
item.title.toLowerCase().includes(searchValue.toLowerCase())
1787+
);
1788+
}, [searchValue]);
1789+
1790+
return (
1791+
<SelectProvider value={values("sources")} setValue={handleChange} virtualFocus={true}>
1792+
{trigger}
1793+
<SelectPopover
1794+
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
1795+
hideOnEscape={() => {
1796+
if (onClose) {
1797+
onClose();
1798+
return false;
1799+
}
1800+
return true;
1801+
}}
1802+
>
1803+
<ComboBox placeholder={"Filter by source..."} value={searchValue} />
1804+
<SelectList>
1805+
{filtered.map((item, index) => (
1806+
<SelectItem
1807+
key={item.value}
1808+
value={item.value}
1809+
icon={
1810+
<TaskTriggerSourceIcon source={item.value} className="size-4 flex-none" />
1811+
}
1812+
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
1813+
>
1814+
{item.title}
1815+
</SelectItem>
1816+
))}
1817+
</SelectList>
1818+
</SelectPopover>
1819+
</SelectProvider>
1820+
);
1821+
}
1822+
1823+
function AppliedSourceFilter() {
1824+
const { values, del } = useSearchParams();
1825+
const sources = values("sources");
1826+
1827+
if (sources.length === 0 || sources.every((v) => v === "")) {
1828+
return null;
1829+
}
1830+
1831+
return (
1832+
<FilterMenuProvider>
1833+
{(search, setSearch) => (
1834+
<SourceDropdown
1835+
trigger={
1836+
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
1837+
<AppliedFilter
1838+
label="Source"
1839+
icon={<CpuChipIcon className="size-4" />}
1840+
value={appliedSummary(
1841+
sources.map(
1842+
(v) => sourceOptions.find((o) => o.value === v)?.title ?? v
1843+
)
1844+
)}
1845+
onRemove={() => del(["sources", "cursor", "direction"])}
1846+
variant="secondary/small"
1847+
/>
1848+
</Ariakit.Select>
1849+
}
1850+
searchValue={search}
1851+
clearSearchValue={() => setSearch("")}
1852+
/>
1853+
)}
1854+
</FilterMenuProvider>
1855+
);
1856+
}

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ import {
5555
filterableTaskRunStatuses,
5656
TaskRunStatusCombo,
5757
} from "./TaskRunStatus";
58+
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
5859
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
5960
import { useSearchParams } from "~/hooks/useSearchParam";
61+
import type { TaskTriggerSource } from "@trigger.dev/database";
6062

6163
type RunsTableProps = {
6264
total: number;
@@ -352,6 +354,10 @@ export function TaskRunsTable({
352354
</TableCell>
353355
<TableCell to={path}>
354356
<span className="flex items-center gap-x-1">
357+
<TaskTriggerSourceIcon
358+
source={run.taskKind as TaskTriggerSource}
359+
className="size-3.5 flex-none"
360+
/>
355361
{run.taskIdentifier}
356362
{run.rootTaskRunId === null ? <Badge variant="extra-small">Root</Badge> : null}
357363
</span>

apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ClockIcon } from "@heroicons/react/20/solid";
1+
import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid";
22
import type { TaskTriggerSource } from "@trigger.dev/database";
33
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
44
import { cn } from "~/utils/cn";
@@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({
1919
<ClockIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-schedules", className)} />
2020
);
2121
}
22+
case "AGENT": {
23+
return (
24+
<CpuChipIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-indigo-500", className)} />
25+
);
26+
}
2227
}
2328
}
2429

@@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
3035
case "SCHEDULED": {
3136
return "Scheduled task";
3237
}
38+
case "AGENT": {
39+
return "Agent";
40+
}
3341
}
3442
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { XCircleIcon } from "@heroicons/react/24/solid";
2+
import { DialogClose } from "@radix-ui/react-dialog";
3+
import { Form, useNavigation } from "@remix-run/react";
4+
import { Button } from "~/components/primitives/Buttons";
5+
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
6+
import { FormButtons } from "~/components/primitives/FormButtons";
7+
import { Input } from "~/components/primitives/Input";
8+
import { Label } from "~/components/primitives/Label";
9+
import { Paragraph } from "~/components/primitives/Paragraph";
10+
import { SpinnerWhite } from "~/components/primitives/Spinner";
11+
12+
type CloseSessionDialogProps = {
13+
sessionParam: string;
14+
environmentId: string;
15+
redirectPath: string;
16+
};
17+
18+
export function CloseSessionDialog({
19+
sessionParam,
20+
environmentId,
21+
redirectPath,
22+
}: CloseSessionDialogProps) {
23+
const navigation = useNavigation();
24+
25+
const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`;
26+
const isLoading = navigation.formAction === formAction;
27+
28+
return (
29+
<DialogContent key="close-session">
30+
<DialogHeader>Close this session?</DialogHeader>
31+
<div className="flex flex-col gap-3 pt-3">
32+
<Paragraph>
33+
Closing a session is permanent. The session will no longer accept new input or trigger
34+
new runs. Any in-flight run continues until it finishes on its own.
35+
</Paragraph>
36+
<Form action={formAction} method="post" className="flex flex-col gap-3">
37+
<input type="hidden" name="redirectUrl" value={redirectPath} />
38+
<input type="hidden" name="environmentId" value={environmentId} />
39+
<div className="flex flex-col gap-1">
40+
<Label htmlFor="close-session-reason">Reason (optional)</Label>
41+
<Input
42+
id="close-session-reason"
43+
name="reason"
44+
placeholder="e.g. user signed out, ticket resolved"
45+
variant="medium"
46+
spellCheck={false}
47+
autoFocus
48+
/>
49+
</div>
50+
<FormButtons
51+
confirmButton={
52+
<Button
53+
type="submit"
54+
variant="danger/medium"
55+
LeadingIcon={isLoading ? SpinnerWhite : XCircleIcon}
56+
disabled={isLoading}
57+
shortcut={{ modifiers: ["mod"], key: "enter" }}
58+
>
59+
{isLoading ? "Closing..." : "Close session"}
60+
</Button>
61+
}
62+
cancelButton={
63+
<DialogClose asChild>
64+
<Button variant={"tertiary/medium"}>Cancel</Button>
65+
</DialogClose>
66+
}
67+
/>
68+
</Form>
69+
</div>
70+
</DialogContent>
71+
);
72+
}

0 commit comments

Comments
 (0)