Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/sessions-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---

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.

```ts
const session = await sessions.create({ externalId: chatId, taskIdentifier: "my-task" });
await session.in.send({ kind: "message", payload: "..." });
for await (const chunk of session.out.read()) { /* ... */ }
```

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.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apps/webapp/app/components/BulkActionFilterSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
117 changes: 116 additions & 1 deletion apps/webapp/app/components/runs/v3/RunFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react";
import {
CalendarIcon,
ClockIcon,
CpuChipIcon,
FingerPrintIcon,
PlusIcon,
RectangleStackIcon,
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -231,6 +235,8 @@ export function filterTitle(filterKey: string) {
return "Version";
case "errorId":
return "Error ID";
case "sources":
return "Source";
default:
return filterKey;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -448,6 +462,7 @@ function AppliedFilters({ bulkActions }: RunFiltersProps) {
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
<AppliedErrorIdFilter />
<AppliedSourceFilter />
</>
);
}
Expand Down Expand Up @@ -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} />;
}
}

Expand Down Expand Up @@ -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>
);
}
6 changes: 6 additions & 0 deletions apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>
Expand Down
10 changes: 9 additions & 1 deletion apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +19,11 @@ export function TaskTriggerSourceIcon({
<ClockIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-schedules", className)} />
);
}
case "AGENT": {
return (
<CpuChipIcon className={cn("size-[1.125rem] min-w-[1.125rem] text-indigo-500", className)} />
);
}
}
}

Expand All @@ -30,5 +35,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) {
case "SCHEDULED": {
return "Scheduled task";
}
case "AGENT": {
return "Agent";
}
}
}
72 changes: 72 additions & 0 deletions apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading