diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx
new file mode 100644
index 0000000000..46938fd391
--- /dev/null
+++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx
@@ -0,0 +1,34 @@
+export function MoveToTopIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx
new file mode 100644
index 0000000000..6e5d8a84ba
--- /dev/null
+++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx
@@ -0,0 +1,41 @@
+export function MoveUpIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx
index 718166b55f..ab328afde7 100644
--- a/apps/webapp/app/components/Shortcuts.tsx
+++ b/apps/webapp/app/components/Shortcuts.tsx
@@ -147,6 +147,12 @@ function ShortcutContent() {
+
+
+
+
+
+
Schedules page
diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx
index 38fd1525c5..d0186268c0 100644
--- a/apps/webapp/app/components/primitives/TextLink.tsx
+++ b/apps/webapp/app/components/primitives/TextLink.tsx
@@ -1,6 +1,10 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
+import { useRef } from "react";
+import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
+import { ShortcutKey } from "./ShortcutKey";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
const variations = {
primary:
@@ -17,6 +21,9 @@ type TextLinkProps = {
trailingIconClassName?: string;
variant?: keyof typeof variations;
children: React.ReactNode;
+ shortcut?: ShortcutDefinition;
+ hideShortcutKey?: boolean;
+ tooltip?: React.ReactNode;
} & React.AnchorHTMLAttributes
;
export function TextLink({
@@ -27,20 +34,61 @@ export function TextLink({
trailingIcon,
trailingIconClassName,
variant = "primary",
+ shortcut,
+ hideShortcutKey,
+ tooltip,
...props
}: TextLinkProps) {
+ const innerRef = useRef(null);
const classes = variations[variant];
- return to ? (
-
+
+ if (shortcut) {
+ useShortcutKeys({
+ shortcut: shortcut,
+ action: () => {
+ if (innerRef.current) {
+ innerRef.current.click();
+ }
+ },
+ });
+ }
+
+ const renderShortcutKey = () =>
+ shortcut &&
+ !hideShortcutKey && ;
+
+ const linkContent = (
+ <>
{children}{" "}
{trailingIcon && }
+ {shortcut && !tooltip && renderShortcutKey()}
+ >
+ );
+
+ const linkElement = to ? (
+
+ {linkContent}
) : href ? (
-
- {children}{" "}
- {trailingIcon && }
+
+ {linkContent}
) : (
Need to define a path or href
);
+
+ if (tooltip) {
+ return (
+
+
+ {linkElement}
+
+ {tooltip} {shortcut && renderShortcutKey()}
+
+
+
+ );
+ }
+
+ return linkElement;
}
diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts
index d3ea209924..400e872a21 100644
--- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts
@@ -1,6 +1,6 @@
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
-import { prisma, PrismaClient } from "~/db.server";
+import { prisma, type PrismaClient } from "~/db.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { eventRepository } from "~/v3/eventRepository.server";
@@ -58,7 +58,13 @@ export class RunPresenter {
rootTaskRun: {
select: {
friendlyId: true,
- taskIdentifier: true,
+ spanId: true,
+ createdAt: true,
+ },
+ },
+ parentTaskRun: {
+ select: {
+ friendlyId: true,
spanId: true,
createdAt: true,
},
@@ -111,6 +117,7 @@ export class RunPresenter {
completedAt: run.completedAt,
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
rootTaskRun: run.rootTaskRun,
+ parentTaskRun: run.parentTaskRun,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
@@ -202,8 +209,6 @@ export class RunPresenter {
trace: {
rootSpanStatus,
events: events,
- parentRunFriendlyId:
- tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
startedAt: run.startedAt,
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 49a0a70c15..890ed00433 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
@@ -11,7 +11,7 @@ import {
MagnifyingGlassPlusIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
-import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
+import { useLoaderData, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
import { type Virtualizer } from "@tanstack/react-virtual";
import {
@@ -25,7 +25,8 @@ import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
-import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
+import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
+import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence";
import { WarmStartIconWithTooltip } from "~/components/WarmStarts";
@@ -87,7 +88,6 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
- v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -302,8 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
return <>>;
}
- const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } =
- trace;
+ const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
@@ -340,7 +339,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
- parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//instantly close the panel if no span is selected
if (!selectedSpan) {
@@ -358,6 +356,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
shouldLiveReload={shouldLiveReload}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
+ parentRun={run.parentTaskRun}
isCompleted={run.completedAt !== null}
/>
@@ -476,7 +475,6 @@ function NoLogsView({ run, resizable }: LoaderData) {
type TasksTreeViewProps = {
events: TraceEvent[];
selectedId?: string;
- parentRunFriendlyId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
@@ -487,7 +485,10 @@ type TasksTreeViewProps = {
maximumLiveReloadingSetting: number;
rootRun: {
friendlyId: string;
- taskIdentifier: string;
+ spanId: string;
+ } | null;
+ parentRun: {
+ friendlyId: string;
spanId: string;
} | null;
isCompleted: boolean;
@@ -496,7 +497,6 @@ type TasksTreeViewProps = {
function TasksTreeView({
events,
selectedId,
- parentRunFriendlyId,
onSelectedIdChanged,
totalDuration,
rootSpanStatus,
@@ -506,6 +506,7 @@ function TasksTreeView({
shouldLiveReload,
maximumLiveReloadingSetting,
rootRun,
+ parentRun,
isCompleted,
}: TasksTreeViewProps) {
const isAdmin = useHasAdminAccess();
@@ -596,20 +597,30 @@ function TasksTreeView({
id={resizableSettings.tree.tree.id}
default={resizableSettings.tree.tree.default}
min={resizableSettings.tree.tree.min}
- className="pl-3"
>
-
- {rootRun ? (
-
+ {rootRun || parentRun ? (
+
- ) : parentRunFriendlyId ? (
-
) : (
-
+
This is the root task
)}
@@ -628,6 +639,7 @@ function TasksTreeView({
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
+ parentClassName="pl-3"
renderNode={({ node, state, index }) => (
<>
;
}
-function ShowParentLink({
- runFriendlyId,
- spanId,
- isRoot,
+function ShowParentOrRootLinks({
+ relationships,
}: {
- runFriendlyId: string;
- spanId?: string;
- isRoot: boolean;
+ relationships: {
+ root?: {
+ friendlyId: string;
+ spanId: string;
+ isParent?: boolean;
+ };
+ parent?: {
+ friendlyId: string;
+ spanId: string;
+ };
+ };
}) {
- const [mouseOver, setMouseOver] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
- const { spanParam } = useParams();
- const span = spanId ? spanId : spanParam;
+ // Case 1: Root is also the parent
+ if (relationships.root?.isParent === true) {
+ return (
+
+ Jump to root and parent run
+
+
+ }
+ className="text-xs"
+ >
+ Root/parent
+
+ );
+ }
+ // Case 2: Root and Parent are different runs
return (
-
setMouseOver(true)}
- onMouseLeave={() => setMouseOver(false)}
- fullWidth
- textAlignLeft
- shortcut={{ key: "p" }}
- className="flex-1"
- >
- {mouseOver ? (
-
- ) : (
-
+
+ {relationships.root && (
+
+ Jump to root run
+
+
+ }
+ className="text-xs"
+ >
+ Root
+
)}
-
- {isRoot ? "Show root run" : "Show parent run"}
-
-
+ {relationships.parent && (
+
+ Jump to parent run
+
+
+ }
+ className="text-xs"
+ >
+ Parent
+
+ )}
+
);
}
diff --git a/apps/webapp/app/v3/services/completeAttempt.server.ts b/apps/webapp/app/v3/services/completeAttempt.server.ts
index 8fe57f040d..b9033a97e9 100644
--- a/apps/webapp/app/v3/services/completeAttempt.server.ts
+++ b/apps/webapp/app/v3/services/completeAttempt.server.ts
@@ -213,7 +213,7 @@ export class CompleteAttemptService extends BaseService {
taskRunAttempt.friendlyId,
taskRunAttempt.taskRunId,
new Date(),
- "Cancelled by user",
+ "Canceled by user",
env
);
diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
index 942c22f6be..b8d5386ac6 100644
--- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
+++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
@@ -1236,7 +1236,7 @@ export class RunAttemptSystem {
tx?: PrismaClientOrTransaction;
}): Promise {
const prisma = tx ?? this.$.prisma;
- reason = reason ?? "Cancelled by user";
+ reason = reason ?? "Canceled by user";
return startSpan(this.$.tracer, "cancelRun", async (span) => {
return this.$.runLock.lock("cancelRun", [runId], async () => {
diff --git a/references/hello-world/src/trigger/nestedDependencies.ts b/references/hello-world/src/trigger/nestedDependencies.ts
new file mode 100644
index 0000000000..afd25a4a27
--- /dev/null
+++ b/references/hello-world/src/trigger/nestedDependencies.ts
@@ -0,0 +1,74 @@
+import { logger, task, wait } from "@trigger.dev/sdk";
+
+export const nestedDependencies = task({
+ id: "nested-dependencies",
+ run: async ({
+ depth = 0,
+ maxDepth = 6,
+ batchSize = 4,
+ waitSeconds = 1,
+ failAttemptChance = 0,
+ failParents = false,
+ }: {
+ depth?: number;
+ maxDepth?: number;
+ batchSize?: number;
+ waitSeconds?: number;
+ failAttemptChance?: number;
+ failParents?: boolean;
+ }) => {
+ if (depth >= maxDepth) {
+ return;
+ }
+
+ logger.log(`Started ${depth}/${maxDepth} depth`);
+
+ const shouldFail = Math.random() < failAttemptChance;
+ if (shouldFail) {
+ throw new Error(`Failed at ${depth}/${maxDepth} depth`);
+ }
+
+ await wait.for({ seconds: waitSeconds });
+
+ const triggerOrBatch = depth % 2 === 0;
+
+ if (triggerOrBatch) {
+ for (let i = 0; i < batchSize; i++) {
+ const result = await nestedDependencies.triggerAndWait({
+ depth: depth + 1,
+ maxDepth,
+ waitSeconds,
+ failAttemptChance,
+ batchSize,
+ });
+ logger.log(`Triggered complete ${i + 1}/${batchSize}`);
+
+ if (!result.ok && failParents) {
+ throw new Error(`Failed at ${depth}/${maxDepth} depth`);
+ }
+ }
+ } else {
+ const results = await nestedDependencies.batchTriggerAndWait(
+ Array.from({ length: batchSize }, (_, i) => ({
+ payload: {
+ depth: depth + 1,
+ maxDepth,
+ batchSize,
+ waitSeconds,
+ failAttemptChance,
+ },
+ }))
+ );
+ logger.log(`Batch triggered complete`);
+
+ if (results.runs.some((r) => !r.ok) && failParents) {
+ throw new Error(`Failed at ${depth}/${maxDepth} depth`);
+ }
+ }
+
+ logger.log(`Sleep for ${waitSeconds} seconds`);
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
+
+ logger.log(`Finished ${depth}/${maxDepth} depth`);
+ },
+});