{label} :
diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx
index 707f672abe..dd53eb5d63 100644
--- a/apps/webapp/app/components/primitives/DateField.tsx
+++ b/apps/webapp/app/components/primitives/DateField.tsx
@@ -10,12 +10,12 @@ import { Button } from "./Buttons";
const variants = {
small: {
- fieldStyles: "h-5 text-sm rounded-sm px-0.5",
+ fieldStyles: "h-5 text-xs rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "tertiary/small" as const,
},
medium: {
- fieldStyles: "h-7 text-base rounded px-1",
+ fieldStyles: "h-7 text-sm rounded px-1",
nowButtonVariant: "tertiary/medium" as const,
clearButtonVariant: "minimal/medium" as const,
},
diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx
index 51ff076d6d..2853f0b59c 100644
--- a/apps/webapp/app/components/primitives/Select.tsx
+++ b/apps/webapp/app/components/primitives/Select.tsx
@@ -1,14 +1,14 @@
import * as Ariakit from "@ariakit/react";
-import { SelectProps as AriaSelectProps } from "@ariakit/react";
+import { type SelectProps as AriaSelectProps } from "@ariakit/react";
import { SelectValue } from "@ariakit/react-core/select/select-value";
import { Link } from "@remix-run/react";
import * as React from "react";
import { Fragment, useMemo, useState } from "react";
-import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
+import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { ChevronDown } from "lucide-react";
-import { MatchSorterOptions, matchSorter } from "match-sorter";
+import { type MatchSorterOptions, matchSorter } from "match-sorter";
const sizes = {
small: {
diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx
index e5d89f20dd..7c26c9df33 100644
--- a/apps/webapp/app/components/primitives/Switch.tsx
+++ b/apps/webapp/app/components/primitives/Switch.tsx
@@ -3,7 +3,15 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "~/utils/cn";
-import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
+import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
+
+const small = {
+ container:
+ "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
+ root: "h-3 w-6",
+ thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
+ text: "text-xs text-text-dimmed",
+};
const variations = {
large: {
@@ -12,12 +20,15 @@ const variations = {
thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-text-dimmed",
},
- small: {
- container:
- "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
- root: "h-3 w-6",
- thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
- text: "text-xs text-text-dimmed",
+ small,
+ "tertiary/small": {
+ container: small.container,
+ root: cn(
+ small.root,
+ "group-data-[state=unchecked]:bg-charcoal-600 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50"
+ ),
+ thumb: small.thumb,
+ text: cn(small.text, "transition group-hover:text-text-bright"),
},
};
diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx
index e7a9ba5223..95fadc9ed1 100644
--- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx
+++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx
@@ -36,14 +36,7 @@ import {
batchStatusTitle,
descriptionForBatchStatus,
} from "./BatchStatus";
-import {
- AppliedCustomDateRangeFilter,
- AppliedPeriodFilter,
- appliedSummary,
- CreatedAtDropdown,
- CustomDateRangeDropdown,
- FilterMenuProvider,
-} from "./SharedFilters";
+import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters";
export const BatchStatus = z.enum(allBatchStatuses);
@@ -54,8 +47,8 @@ export const BatchListFilters = z.object({
(value) => (typeof value === "string" ? [value] : value),
BatchStatus.array().optional()
),
- period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
id: z.string().optional(),
+ period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
@@ -69,16 +62,12 @@ type BatchFiltersProps = {
export function BatchFilters(props: BatchFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
- const hasFilters =
- searchParams.has("statuses") ||
- searchParams.has("id") ||
- searchParams.has("period") ||
- searchParams.has("from") ||
- searchParams.has("to");
+ const hasFilters = searchParams.has("statuses") || searchParams.has("id");
return (
),
},
- { name: "created", title: "Created", icon:
},
- { name: "daterange", title: "Custom date range", icon:
},
{ name: "batch", title: "Batch ID", icon:
},
] as const;
@@ -148,8 +135,6 @@ function AppliedFilters() {
return (
<>
-
-
>
);
@@ -169,10 +154,6 @@ function Menu(props: MenuProps) {
return
;
case "statuses":
return
props.setFilterType(undefined)} {...props} />;
- case "created":
- return props.setFilterType(undefined)} {...props} />;
- case "daterange":
- return props.setFilterType(undefined)} {...props} />;
case "batch":
return props.setFilterType(undefined)} {...props} />;
}
@@ -181,7 +162,6 @@ function Menu(props: MenuProps) {
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
- if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx
index 591b917bd8..5529033b21 100644
--- a/apps/webapp/app/components/runs/v3/RunFilters.tsx
+++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx
@@ -1,8 +1,6 @@
import * as Ariakit from "@ariakit/react";
import {
- CalendarIcon,
ClockIcon,
- CpuChipIcon,
FingerPrintIcon,
Squares2X2Icon,
TagIcon,
@@ -14,6 +12,7 @@ import { ListChecks, ListFilterIcon } from "lucide-react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useCallback, useEffect, 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 { DateTime } from "~/components/primitives/DateTime";
@@ -45,14 +44,7 @@ import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionStatusCombo } from "./BulkAction";
-import {
- AppliedCustomDateRangeFilter,
- AppliedPeriodFilter,
- appliedSummary,
- CreatedAtDropdown,
- CustomDateRangeDropdown,
- FilterMenuProvider,
-} from "./SharedFilters";
+import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
import {
allTaskRunStatuses,
descriptionForTaskRunStatus,
@@ -61,7 +53,6 @@ import {
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
-import { StatusIcon } from "~/assets/icons/StatusIcon";
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
@@ -88,8 +79,8 @@ export const TaskRunListSearchFilters = z.object({
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
- period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
bulkId: z.string().optional(),
+ period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
rootOnly: z.coerce.boolean().optional(),
@@ -117,11 +108,8 @@ export function RunsFilters(props: RunFiltersProps) {
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("tasks") ||
- searchParams.has("period") ||
searchParams.has("bulkId") ||
searchParams.has("tags") ||
- searchParams.has("from") ||
- searchParams.has("to") ||
searchParams.has("batchId") ||
searchParams.has("runId") ||
searchParams.has("scheduleId");
@@ -130,6 +118,7 @@ export function RunsFilters(props: RunFiltersProps) {
}
- variant={"minimal/small"}
+ variant={"tertiary/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
@@ -205,8 +192,6 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
-
-
@@ -229,13 +214,8 @@ function Menu(props: MenuProps) {
return ;
case "statuses":
return props.setFilterType(undefined)} {...props} />;
-
case "tasks":
return props.setFilterType(undefined)} {...props} />;
- case "created":
- return props.setFilterType(undefined)} {...props} />;
- case "daterange":
- return props.setFilterType(undefined)} {...props} />;
case "bulk":
return props.setFilterType(undefined)} {...props} />;
case "tags":
@@ -252,7 +232,6 @@ function Menu(props: MenuProps) {
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
- if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
@@ -699,9 +678,10 @@ function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
return (
{
replace({
rootOnly: checked ? "true" : "false",
diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx
index dc417473e9..5b7478d6a1 100644
--- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx
+++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx
@@ -1,19 +1,13 @@
import * as Ariakit from "@ariakit/react";
import type { RuntimeEnvironment } from "@trigger.dev/database";
+import parse from "parse-duration";
import type { ReactNode } from "react";
-import { startTransition, useCallback, useMemo, useState } from "react";
+import { startTransition, useCallback, useState } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { DateField } from "~/components/primitives/DateField";
import { DateTime } from "~/components/primitives/DateTime";
import { Label } from "~/components/primitives/Label";
-import {
- ComboBox,
- ComboboxProvider,
- SelectItem,
- SelectList,
- SelectPopover,
- SelectProvider,
-} from "~/components/primitives/Select";
+import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
@@ -51,200 +45,204 @@ export function FilterMenuProvider({
const timePeriods = [
{
- label: "Last 5 mins",
+ label: "1 min",
+ value: "1m",
+ },
+ {
+ label: "5 mins",
value: "5m",
},
{
- label: "Last 30 mins",
+ label: "30 mins",
value: "30m",
},
{
- label: "Last 1 hour",
+ label: "1 hr",
value: "1h",
},
{
- label: "Last 6 hours",
+ label: "6 hrs",
value: "6h",
},
{
- label: "Last 1 day",
+ label: "12 hrs",
+ value: "12h",
+ },
+ {
+ label: "1 day",
value: "1d",
},
{
- label: "Last 3 days",
+ label: "3 days",
value: "3d",
},
{
- label: "Last 7 days",
+ label: "7 days",
value: "7d",
},
{
- label: "Last 14 days",
+ label: "14 days",
value: "14d",
},
{
- label: "Last 30 days",
+ label: "30 days",
value: "30d",
},
{
- label: "All periods",
- value: "all",
+ label: "1 year",
+ value: "365d",
},
];
-export function CreatedAtDropdown({
- trigger,
- clearSearchValue,
- searchValue,
- onClose,
- setFilterType,
- hideCustomRange,
-}: {
- trigger: ReactNode;
- clearSearchValue: () => void;
- searchValue: string;
- onClose?: () => void;
- setFilterType?: (type: "daterange" | undefined) => void;
- hideCustomRange?: boolean;
-}) {
- const { value, replace } = useSearchParams();
-
- const from = value("from");
- const to = value("to");
- const period = value("period");
+const defaultPeriod = "7d";
+const defaultPeriodMs = parse(defaultPeriod);
+if (!defaultPeriodMs) {
+ throw new Error("Invalid default period");
+}
- const handleChange = (newValue: string) => {
- clearSearchValue();
- if (newValue === "all") {
- if (!period && !from && !to) return;
+export const timeFilters = ({
+ period,
+ from,
+ to,
+}: {
+ period?: string;
+ from?: string | number;
+ to?: string | number;
+}): { period?: string; from?: Date; to?: Date; isDefault: boolean } => {
+ if (period) {
+ return { period, isDefault: period === defaultPeriod };
+ }
- replace({
- period: undefined,
- from: undefined,
- to: undefined,
- cursor: undefined,
- direction: undefined,
- });
- return;
- }
+ if (from && to) {
+ return {
+ from: typeof from === "string" ? dateFromString(from) : new Date(from),
+ to: typeof to === "string" ? dateFromString(to) : new Date(to),
+ isDefault: false,
+ };
+ }
- if (newValue === "custom") {
- setFilterType?.("daterange");
- return;
- }
+ if (from) {
+ const fromDate = typeof from === "string" ? dateFromString(from) : new Date(from);
- replace({
- period: newValue,
- from: undefined,
- to: undefined,
- cursor: undefined,
- direction: undefined,
- });
- };
+ return {
+ from: fromDate,
+ isDefault: false,
+ };
+ }
- const filtered = useMemo(() => {
- return timePeriods.filter((item) =>
- item.label.toLowerCase().includes(searchValue.toLowerCase())
- );
- }, [searchValue]);
+ if (to) {
+ const toDate = typeof to === "string" ? dateFromString(to) : new Date(to);
- return (
-
- {trigger}
- {
- if (onClose) {
- onClose();
- return false;
- }
+ return {
+ to: toDate,
+ isDefault: false,
+ };
+ }
- return true;
- }}
- >
-
-
- {filtered.map((item) => (
-
- {item.label}
-
- ))}
- {!hideCustomRange ? (
-
- Custom date range
-
- ) : null}
-
-
-
- );
-}
+ return {
+ period: defaultPeriod,
+ isDefault: true,
+ };
+};
-export function AppliedPeriodFilter() {
+export function TimeFilter() {
const { value, del } = useSearchParams();
- if (value("period") === undefined || value("period") === "all") {
- return null;
+ const { period, from, to } = timeFilters({
+ period: value("period"),
+ from: value("from"),
+ to: value("to"),
+ });
+
+ const rangeType = from && to ? "range" : from ? "from" : to ? "to" : "period";
+ let valueLabel: ReactNode;
+ switch (rangeType) {
+ case "period":
+ valueLabel = timePeriods.find((t) => t.value === period)?.label ?? period ?? defaultPeriod;
+ break;
+ case "range":
+ valueLabel = (
+
+ –{" "}
+
+
+ );
+ break;
+ case "from":
+ valueLabel = ;
+ break;
+ case "to":
+ valueLabel = ;
+ break;
}
+ let label =
+ rangeType === "range" || rangeType === "period"
+ ? "Created"
+ : rangeType === "from"
+ ? "Created after"
+ : "Created before";
+
return (
- {(search, setSearch) => (
- (
+ }>
- t.value === value("period"))?.label ?? value("period")
- }
- onRemove={() => del(["period", "cursor", "direction"])}
- />
+
}
- searchValue={search}
- clearSearchValue={() => setSearch("")}
- hideCustomRange
+ period={period}
+ from={from}
+ to={to}
/>
)}
);
}
-export function CustomDateRangeDropdown({
+export function TimeDropdown({
trigger,
- clearSearchValue,
- searchValue,
- onClose,
+ period,
+ from,
+ to,
}: {
trigger: ReactNode;
- clearSearchValue: () => void;
- searchValue: string;
- onClose?: () => void;
+ period?: string;
+ from?: Date;
+ to?: Date;
}) {
const [open, setOpen] = useState();
- const { value, replace } = useSearchParams();
- const fromSearch = dateFromString(value("from"));
- const toSearch = dateFromString(value("to"));
- const [from, setFrom] = useState(fromSearch);
- const [to, setTo] = useState(toSearch);
+ const { replace } = useSearchParams();
+ const [fromValue, setFromValue] = useState(from);
+ const [toValue, setToValue] = useState(to);
const apply = useCallback(() => {
- clearSearchValue();
replace({
period: undefined,
cursor: undefined,
direction: undefined,
- from: from?.getTime().toString(),
- to: to?.getTime().toString(),
+ from: fromValue?.getTime().toString(),
+ to: toValue?.getTime().toString(),
});
setOpen(false);
- }, [from, to, replace]);
+ }, [fromValue, toValue, replace]);
+
+ const handlePeriodClick = useCallback((period: string) => {
+ setFromValue(undefined);
+ setToValue(undefined);
+
+ replace({
+ period: period,
+ cursor: undefined,
+ direction: undefined,
+ from: undefined,
+ to: undefined,
+ });
+
+ setOpen(false);
+ }, []);
return (
@@ -252,54 +250,84 @@ export function CustomDateRangeDropdown({
{
- if (onClose) {
- onClose();
- return false;
- }
-
return true;
}}
>
-
+
-
From (local time)
-
+
Runs created in the last
+
+ {timePeriods.map((p) => (
+ handlePeriodClick(p.value)}
+ fullWidth
+ >
+ {p.label}
+
+ ))}
+
-
- To (local time)
-
-
-
-
setOpen(false)}>
- Cancel
-
-
apply()}
- >
- Apply
-
+
+
+
+
+ From (local time)
+
+
+
+
+
+ To (local time)
+
+
+
+
+ {
+ setFromValue(from);
+ setToValue(to);
+ setOpen(false);
+ }}
+ >
+ Cancel
+
+ apply()}
+ >
+ Apply
+
+
@@ -307,58 +335,6 @@ export function CustomDateRangeDropdown({
);
}
-export function AppliedCustomDateRangeFilter() {
- const { value, del } = useSearchParams();
-
- if (value("from") === undefined && value("to") === undefined) {
- return null;
- }
-
- const fromDate = dateFromString(value("from"));
- const toDate = dateFromString(value("to"));
-
- const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to";
-
- return (
-
- {(search, setSearch) => (
- }>
-
- {rangeType === "range" ? (
-
- –{" "}
-
-
- ) : rangeType === "from" ? (
-
- ) : (
-
- )}
- >
- }
- onRemove={() => del(["period", "from", "to", "cursor", "direction"])}
- />
-
- }
- searchValue={search}
- clearSearchValue={() => setSearch("")}
- />
- )}
-
- );
-}
-
export function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
return null;
diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
index 33fb88b2e0..92c773f567 100644
--- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
+++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx
@@ -288,7 +288,7 @@ export function TaskRunsTable({
) : (
runs.map((run, index) => {
- const path = v3RunSpanPath(organization, project, environment, run, {
+ const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
});
return (
diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx
index 73ff03e5da..7c64647628 100644
--- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx
+++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx
@@ -36,14 +36,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as tagsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags";
-import {
- AppliedCustomDateRangeFilter,
- AppliedPeriodFilter,
- appliedSummary,
- CreatedAtDropdown,
- CustomDateRangeDropdown,
- FilterMenuProvider,
-} from "./SharedFilters";
+import { TimeFilter, appliedSummary, FilterMenuProvider } from "./SharedFilters";
import { WaitpointStatusCombo, waitpointStatusTitle } from "./WaitpointStatus";
export const WaitpointSearchParamsSchema = z.object({
@@ -71,16 +64,14 @@ export function WaitpointTokenFilters(props: WaitpointTokenFiltersProps) {
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
- searchParams.has("period") ||
searchParams.has("tags") ||
- searchParams.has("from") ||
- searchParams.has("to") ||
searchParams.has("id") ||
searchParams.has("idempotencyKey");
return (
)}
@@ -186,15 +188,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
- {batches.length === 0 && !hasFilters ? (
-
- {!isLoading && (
-
- )}
-
- ) : batches.length === 0 ? (
+ {batches.length === 0 ? (
No batches match these filters
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
index cbd2875206..8fd725ee3d 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx
@@ -18,6 +18,7 @@ import {
formatDurationMilliseconds,
millisecondsToNanoseconds,
nanosecondsToMilliseconds,
+ tryCatch,
} from "@trigger.dev/core/v3";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
@@ -77,7 +78,7 @@ import { useProject } from "~/hooks/useProject";
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess } from "~/hooks/useUser";
-import { RunPresenter } from "~/presenters/v3/RunPresenter.server";
+import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
@@ -88,12 +89,15 @@ import {
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
+ v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
+import { redirectWithErrorMessage } from "~/models/message.server";
+import { redirect } from "remix-typedjson";
const resizableSettings = {
parent: {
@@ -133,13 +137,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params);
const presenter = new RunPresenter();
- const result = await presenter.call({
- userId,
- organizationSlug,
- showDeletedLogs: !!impersonationId,
- projectSlug: projectParam,
- runFriendlyId: runParam,
- });
+ const [error, result] = await tryCatch(
+ presenter.call({
+ userId,
+ organizationSlug,
+ showDeletedLogs: !!impersonationId,
+ projectSlug: projectParam,
+ runFriendlyId: runParam,
+ environmentSlug: envParam,
+ })
+ );
+
+ if (error) {
+ if (error instanceof RunEnvironmentMismatchError) {
+ throw redirect(
+ v3RunRedirectPath(
+ { slug: organizationSlug },
+ { slug: projectParam },
+ { friendlyId: runParam }
+ )
+ );
+ }
+
+ throw error;
+ }
//resizable settings
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
index 9158a2f084..2bf0ef79af 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx
@@ -201,7 +201,7 @@ export default function Page() {
{(list) => (
<>
- {list.runs.length === 0 && !list.hasFilters ? (
+ {list.runs.length === 0 && !list.hasAnyRuns ? (
list.possibleTasks.length === 0 ? (
) : (
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
index ad3cccab3f..5ab537f568 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
@@ -1,4 +1,3 @@
-import upgradeForWaitpointsPath from "~/assets/images/waitpoints-dashboard.png";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
@@ -8,6 +7,7 @@ import { NoWaitpointTokens } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { ListPagination } from "~/components/ListPagination";
import { LinkButton } from "~/components/primitives/Buttons";
+import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -39,8 +39,6 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { WaitpointTokenListPresenter } from "~/presenters/v3/WaitpointTokenListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder";
-import { determineEngineVersion } from "~/v3/engineVersion.server";
-import { CopyableText } from "~/components/primitives/CopyableText";
export const meta: MetaFunction = () => {
return [
@@ -103,7 +101,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
- const { success, tokens, pagination, hasFilters, filters } = useTypedLoaderData();
+ const { success, tokens, pagination, hasFilters, hasAnyTokens, filters } =
+ useTypedLoaderData();
const organization = useOrganization();
const project = useProject();
@@ -124,7 +123,7 @@ export default function Page() {
- {!hasFilters && tokens.length === 0 ? (
+ {!hasAnyTokens ? (
diff --git a/apps/webapp/app/routes/engine.v1.dev.presence.ts b/apps/webapp/app/routes/engine.v1.dev.presence.ts
index 8ddfdf2575..bb38a88f25 100644
--- a/apps/webapp/app/routes/engine.v1.dev.presence.ts
+++ b/apps/webapp/app/routes/engine.v1.dev.presence.ts
@@ -1,23 +1,13 @@
import { json } from "@remix-run/server-runtime";
-import { Redis } from "ioredis";
import { env } from "~/env.server";
-import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server";
+import { devPresence } from "~/presenters/v3/DevPresence.server";
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createSSELoader } from "~/utils/sse";
-const redis = new Redis({
- port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined,
- host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined,
- username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined,
- password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined,
- enableAutoPipelining: true,
- ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
-});
-
export const loader = createSSELoader({
- timeout: env.DEV_PRESENCE_TTL_MS,
- interval: env.DEV_PRESENCE_POLL_INTERVAL_MS,
+ timeout: env.DEV_PRESENCE_SSE_TIMEOUT,
+ interval: env.DEV_PRESENCE_TTL_MS * 0.8,
debug: true,
handler: async ({ id, controller, debug, request }) => {
const authentication = await authenticateApiRequestWithFailure(request);
@@ -27,52 +17,24 @@ export const loader = createSSELoader({
}
const environmentId = authentication.environment.id;
-
- const presenceKey = DevPresenceStream.getPresenceKey(environmentId);
- const presenceChannel = DevPresenceStream.getPresenceChannel(environmentId);
+ const ttl = env.DEV_PRESENCE_TTL_MS / 1000;
return {
beforeStream: async () => {
logger.debug("Start dev presence SSE session", {
environmentId,
- presenceKey,
- presenceChannel,
});
},
initStream: async ({ send }) => {
// Set initial presence with more context
- await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, new Date().toISOString());
-
- // Publish presence update
- await redis.publish(
- presenceChannel,
- JSON.stringify({
- type: "connected",
- environmentId,
- timestamp: Date.now(),
- })
- );
-
+ await devPresence.setConnected(environmentId, ttl);
send({ event: "start", data: `Started ${id}` });
},
iterator: async ({ send, date }) => {
- await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, date.toISOString());
-
+ await devPresence.setConnected(environmentId, ttl);
send({ event: "time", data: new Date().toISOString() });
},
- cleanup: async () => {
- await redis.del(presenceKey);
-
- // Publish disconnect event
- await redis.publish(
- presenceChannel,
- JSON.stringify({
- type: "disconnected",
- environmentId,
- timestamp: Date.now(),
- })
- );
- },
+ cleanup: async () => {},
};
},
});
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx
new file mode 100644
index 0000000000..12ee82b8e9
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.dev.presence.tsx
@@ -0,0 +1,74 @@
+import { $replica } from "~/db.server";
+import { env } from "~/env.server";
+import { devPresence } from "~/presenters/v3/DevPresence.server";
+import { logger } from "~/services/logger.server";
+import { requireUserId } from "~/services/session.server";
+import { ProjectParamSchema } from "~/utils/pathBuilder";
+import { createSSELoader, type SendFunction } from "~/utils/sse";
+
+export const loader = createSSELoader({
+ timeout: env.DEV_PRESENCE_SSE_TIMEOUT,
+ interval: env.DEV_PRESENCE_POLL_MS,
+ debug: true,
+ handler: async ({ id, controller, debug, request, params }) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
+
+ const environment = await $replica.runtimeEnvironment.findFirst({
+ where: {
+ type: "DEVELOPMENT",
+ orgMember: {
+ userId,
+ },
+ project: {
+ slug: projectParam,
+ },
+ },
+ });
+
+ if (!environment) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ const checkAndSendPresence = async (send: SendFunction) => {
+ try {
+ // Use the command client for the GET operation
+ const isConnected = await devPresence.isConnected(environment.id);
+
+ send({
+ event: "presence",
+ data: JSON.stringify({
+ isConnected,
+ environmentId: environment.id,
+ timestamp: new Date().toISOString(),
+ }),
+ });
+
+ return isConnected;
+ } catch (error) {
+ // Handle the case where the controller is closed
+ logger.debug("Failed to send presence data, stream might be closed", { error });
+ return false;
+ }
+ };
+
+ return {
+ beforeStream: async () => {
+ logger.debug("Start dev presence listening SSE session", {
+ environmentId: environment.id,
+ });
+ },
+ initStream: async ({ send }) => {
+ await checkAndSendPresence(send);
+
+ send({ event: "time", data: new Date().toISOString() });
+ },
+ iterator: async ({ send, date }) => {
+ await checkAndSendPresence(send);
+ },
+ cleanup: async ({ send }) => {
+ await checkAndSendPresence(send);
+ },
+ };
+ },
+});
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx
deleted file mode 100644
index 33ec558d2d..0000000000
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { $replica } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
-import { EnvironmentParamSchema } from "~/utils/pathBuilder";
-import { env } from "~/env.server";
-import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server";
-import { logger } from "~/services/logger.server";
-import { createSSELoader, type SendFunction } from "~/utils/sse";
-import Redis from "ioredis";
-
-export const loader = createSSELoader({
- timeout: env.DEV_PRESENCE_TTL_MS,
- interval: env.DEV_PRESENCE_POLL_INTERVAL_MS,
- debug: true,
- handler: async ({ id, controller, debug, request, params }) => {
- const userId = await requireUserId(request);
- const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
-
- const environment = await $replica.runtimeEnvironment.findFirst({
- where: {
- slug: envParam,
- type: "DEVELOPMENT",
- orgMember: {
- userId,
- },
- project: {
- slug: projectParam,
- },
- },
- });
-
- if (!environment) {
- throw new Response("Not Found", { status: 404 });
- }
-
- const presenceKey = DevPresenceStream.getPresenceKey(environment.id);
- const presenceChannel = DevPresenceStream.getPresenceChannel(environment.id);
-
- // Create two Redis clients - one for subscribing and one for regular commands
- const redisConfig = {
- port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined,
- host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined,
- username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined,
- password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined,
- enableAutoPipelining: true,
- ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
- };
-
- // Subscriber client for pubsub
- const subRedis = new Redis(redisConfig);
-
- // Command client for regular Redis commands
- const cmdRedis = new Redis(redisConfig);
-
- const checkAndSendPresence = async (send: SendFunction) => {
- try {
- // Use the command client for the GET operation
- const currentPresenceValue = await cmdRedis.get(presenceKey);
- const isConnected = !!currentPresenceValue;
-
- // Format lastSeen as ISO string if it exists
- let lastSeen = null;
- if (currentPresenceValue) {
- try {
- lastSeen = new Date(currentPresenceValue).toISOString();
- } catch (e) {
- // If parsing fails, use current time as fallback
- lastSeen = new Date().toISOString();
- logger.warn("Failed to parse lastSeen value, using current time", {
- originalValue: currentPresenceValue,
- });
- }
- }
-
- send({
- event: "presence",
- data: JSON.stringify({
- type: isConnected ? "connected" : "disconnected",
- environmentId: environment.id,
- timestamp: new Date().toISOString(), // Also standardize this to ISO
- lastSeen: lastSeen,
- }),
- });
-
- return isConnected;
- } catch (error) {
- // Handle the case where the controller is closed
- logger.debug("Failed to send presence data, stream might be closed", { error });
- return false;
- }
- };
-
- return {
- beforeStream: async () => {
- logger.debug("Start dev presence listening SSE session", {
- environmentId: environment.id,
- presenceChannel,
- });
- },
- initStream: async ({ send }) => {
- await checkAndSendPresence(send);
-
- //start subscribing with the subscriber client
- await subRedis.subscribe(presenceChannel);
-
- subRedis.on("message", async (channel, message) => {
- if (channel === presenceChannel) {
- try {
- await checkAndSendPresence(send);
- } catch (error) {
- logger.error("Failed to parse presence message", { error, message });
- }
- }
- });
-
- send({ event: "time", data: new Date().toISOString() });
- },
- iterator: async ({ send, date }) => {
- await checkAndSendPresence(send);
- },
- cleanup: async ({ send }) => {
- await checkAndSendPresence(send);
-
- await subRedis.unsubscribe(presenceChannel);
- await subRedis.quit();
- await cmdRedis.quit();
- },
- };
- },
-});
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts
index 765c711404..e8e5eea364 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tags.ts
@@ -31,7 +31,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const presenter = new WaitpointTagListPresenter();
const result = await presenter.call({
environmentId: environment.id,
- names: name ? [decodeURIComponent(name)] : undefined,
+ name: name ? decodeURIComponent(name) : undefined,
});
return result;
}
diff --git a/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx
index 2f3df1d140..449142f53c 100644
--- a/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx
+++ b/apps/webapp/app/routes/resources.projects.$projectParam.runs.tags.tsx
@@ -26,7 +26,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const presenter = new RunTagListPresenter();
const result = await presenter.call({
projectId: project.id,
- names: name ? [decodeURIComponent(name)] : undefined,
+ name: name ? decodeURIComponent(name) : undefined,
});
return result;
}
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 76c440c823..dc6b392dcf 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -242,6 +242,14 @@ export function v3RunPath(
return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`;
}
+export function v3RunRedirectPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ run: v3RunForPath
+) {
+ return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
+}
+
export function v3RunDownloadLogsPath(run: v3RunForPath) {
return `/resources/runs/${run.friendlyId}/logs/download`;
}
diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
index af63a15033..b2a56f78cc 100644
--- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
+++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
@@ -1,12 +1,13 @@
-import { PrismaClientOrTransaction } from "~/db.server";
-import { BaseService } from "./baseService.server";
+import { stringifyIO } from "@trigger.dev/core/v3";
+import { type PrismaClientOrTransaction } from "~/db.server";
+import { devPresence } from "~/presenters/v3/DevPresence.server";
+import { logger } from "~/services/logger.server";
import { workerQueue } from "~/services/worker.server";
+import { findCurrentWorkerDeployment } from "../models/workerDeployment.server";
+import { nextScheduledTimestamps } from "../utils/calculateNextSchedule.server";
+import { BaseService } from "./baseService.server";
import { RegisterNextTaskScheduleInstanceService } from "./registerNextTaskScheduleInstance.server";
import { TriggerTaskService } from "./triggerTask.server";
-import { stringifyIO } from "@trigger.dev/core/v3";
-import { nextScheduledTimestamps } from "../utils/calculateNextSchedule.server";
-import { findCurrentWorkerDeployment } from "../models/workerDeployment.server";
-import { logger } from "~/services/logger.server";
export class TriggerScheduledTaskService extends BaseService {
public async call(instanceId: string, finalAttempt: boolean) {
@@ -57,11 +58,17 @@ export class TriggerScheduledTaskService extends BaseService {
shouldTrigger = false;
}
- if (
- instance.environment.type === "DEVELOPMENT" &&
- (!instance.environment.currentSession || instance.environment.currentSession.disconnectedAt)
- ) {
- shouldTrigger = false;
+ if (instance.environment.type === "DEVELOPMENT") {
+ //v3
+ const v3Disconnected =
+ !instance.environment.currentSession ||
+ instance.environment.currentSession.disconnectedAt;
+ //v4
+ const v4Connected = await devPresence.isConnected(instance.environment.id);
+
+ if (v3Disconnected && !v4Connected) {
+ shouldTrigger = false;
+ }
}
if (instance.environment.type !== "DEVELOPMENT") {
@@ -147,6 +154,15 @@ export class TriggerScheduledTaskService extends BaseService {
scheduleInstanceId: instance.id,
},
});
+
+ await this._prisma.taskSchedule.update({
+ where: {
+ id: instance.taskSchedule.id,
+ },
+ data: {
+ lastRunTriggeredAt: new Date(),
+ },
+ });
}
}
diff --git a/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql b/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql
new file mode 100644
index 0000000000..413f8a85e4
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250327181650_add_last_run_triggered_at_to_task_schedule/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "TaskSchedule"
+ADD COLUMN "lastRunTriggeredAt" TIMESTAMP(3);
\ No newline at end of file
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index afe7256fb5..216ed36851 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -2918,6 +2918,8 @@ model TaskSchedule {
///Instances of the schedule that are active
instances TaskScheduleInstance[]
+ lastRunTriggeredAt DateTime?
+
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index 20d9c61081..6e97cfed0f 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -494,11 +494,15 @@ export class CliApiClient {
});
}
- private async devPresenceConnection(): Promise {
+ private devPresenceConnection(): EventSource {
if (!this.accessToken) {
throw new Error("connectToPresence: No access token");
}
+ let retryCount = 0;
+ const maxRetries = 5;
+ const retryDelay = 1000; // Start with 1 second delay
+
const eventSource = new EventSource(`${this.apiURL}/engine/v1/dev/presence`, {
fetch: (input, init) =>
fetch(input, {
@@ -510,26 +514,40 @@ export class CliApiClient {
}),
});
- return new Promise((resolve, reject) => {
- eventSource.onopen = () => {
- logger.debug("Presence connection established");
- resolve(eventSource);
- };
-
- eventSource.onerror = (error: any) => {
- // The connection will automatically try to reconnect
- logger.debug("Presence connection error, will automatically attempt to reconnect", {
- error,
- readyState: eventSource.readyState, // 0 = connecting, 1 = open, 2 = closed
- });
-
- // If you want to detect when it's permanently failed and not reconnecting
- if (eventSource.readyState === EventSource.CLOSED) {
- logger.debug("Presence connection permanently closed", { error });
- reject(new Error(`Failed to connect to ${this.apiURL}`));
+ eventSource.onopen = () => {
+ logger.debug("Presence connection established");
+ retryCount = 0; // Reset retry count on successful connection
+ };
+
+ eventSource.onerror = (error: any) => {
+ // The connection will automatically try to reconnect
+ logger.debug("Presence connection error, will automatically attempt to reconnect", {
+ error,
+ readyState: eventSource.readyState,
+ });
+
+ if (eventSource.readyState === EventSource.CLOSED) {
+ logger.debug("Presence connection permanently closed", { error, retryCount });
+
+ if (retryCount < maxRetries) {
+ retryCount++;
+ const backoffDelay = retryDelay * Math.pow(2, retryCount - 1); // Exponential backoff
+
+ logger.debug(
+ `Attempting reconnection in ${backoffDelay}ms (attempt ${retryCount}/${maxRetries})`
+ );
+ eventSource.close();
+
+ setTimeout(() => {
+ this.devPresenceConnection();
+ }, backoffDelay);
+ } else {
+ logger.debug("Max retry attempts reached, giving up");
}
- };
- });
+ }
+ };
+
+ return eventSource;
}
private async devDequeue(
diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts
index 81d7c77f9f..e1445b4600 100644
--- a/packages/cli-v3/src/dev/devSupervisor.ts
+++ b/packages/cli-v3/src/dev/devSupervisor.ts
@@ -333,7 +333,7 @@ class DevSupervisor implements WorkerRuntime {
async #startPresenceConnection() {
try {
- const eventSource = await this.options.client.dev.presenceConnection();
+ const eventSource = this.options.client.dev.presenceConnection();
// Regular "ping" messages
eventSource.addEventListener("presence", (event: any) => {