From 12947e0e8710cba21fab4479c8f8028b1c0979ca Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 10:29:31 +0530 Subject: [PATCH 01/13] Apply throttle changes --- apps/webapp/app/utils/throttle.ts | 32 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/utils/throttle.ts b/apps/webapp/app/utils/throttle.ts index a6c1a77a32..24f904fb29 100644 --- a/apps/webapp/app/utils/throttle.ts +++ b/apps/webapp/app/utils/throttle.ts @@ -1,20 +1,28 @@ -//From: https://kettanaito.com/blog/debounce-vs-throttle - -/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */ +/** A throttle that fires the first call immediately and ensures the last call during the duration is also fired. */ export function throttle( func: (...args: any[]) => void, durationMs: number ): (...args: any[]) => void { - let isPrimedToFire = false; - - return (...args: any[]) => { - if (!isPrimedToFire) { - isPrimedToFire = true; + let timeoutId: NodeJS.Timeout | null = null; + let nextArgs: any[] | null = null; - setTimeout(() => { - func(...args); - isPrimedToFire = false; - }, durationMs); + const wrapped = (...args: any[]) => { + if (timeoutId) { + nextArgs = args; + return; } + + func(...args); + + timeoutId = setTimeout(() => { + timeoutId = null; + if (nextArgs) { + const argsToUse = nextArgs; + nextArgs = null; + wrapped(...argsToUse); + } + }, durationMs); }; + + return wrapped; } From 20362afa2c0c403b055b7db677d96fb033a6bb04 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 11:34:26 +0530 Subject: [PATCH 02/13] fix(webapp): reconcile trace with run lifecycle to handle clickhouse lag --- .../app/presenters/v3/RunPresenter.server.ts | 139 ++++++++++++----- apps/webapp/test/RunPresenter.test.ts | 141 ++++++++++++++++++ 2 files changed, 241 insertions(+), 39 deletions(-) create mode 100644 apps/webapp/test/RunPresenter.test.ts diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 5e8dab2d0b..3ffd8010ee 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -6,7 +6,7 @@ import { getUsername } from "~/utils/username"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { SpanSummary } from "~/v3/eventRepository/eventRepository.types"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; -import { isFinalRunStatus } from "~/v3/taskStatus"; +import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { env } from "~/env.server"; type Result = Awaited>; @@ -215,55 +215,53 @@ export class RunPresenter { const events = tree ? flattenTree(tree).map((n) => { - const offset = millisecondsToNanoseconds( - n.data.startTime.getTime() - treeRootStartTimeMs - ); - //only let non-debug events extend the total duration - if (!n.data.isDebug) { - totalDuration = Math.max(totalDuration, offset + n.data.duration); - } + const offset = millisecondsToNanoseconds( + n.data.startTime.getTime() - treeRootStartTimeMs + ); + //only let non-debug events extend the total duration + if (!n.data.isDebug) { + totalDuration = Math.max(totalDuration, offset + n.data.duration); + } - // For cached spans, store the mapping from spanId to the linked run's ID - if (n.data.style?.icon === "task-cached" && n.runId) { - linkedRunIdBySpanId[n.id] = n.runId; - } + // For cached spans, store the mapping from spanId to the linked run's ID + if (n.data.style?.icon === "task-cached" && n.runId) { + linkedRunIdBySpanId[n.id] = n.runId; + } - return { - ...n, - data: { - ...n.data, - timelineEvents: createTimelineSpanEventsFromSpanEvents( - n.data.events, - user?.admin ?? false, - treeRootStartTimeMs - ), - //set partial nodes to null duration - duration: n.data.isPartial ? null : n.data.duration, - offset, - isRoot: n.id === traceSummary.rootSpan.id, - }, - }; - }) + return { + ...n, + data: { + ...n.data, + timelineEvents: createTimelineSpanEventsFromSpanEvents( + n.data.events, + user?.admin ?? false, + treeRootStartTimeMs + ), + //set partial nodes to null duration + duration: n.data.isPartial ? null : n.data.duration, + offset, + isRoot: n.id === traceSummary.rootSpan.id, + }, + }; + }) : []; //total duration should be a minimum of 1ms totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1)); - let rootSpanStatus: "executing" | "completed" | "failed" = "executing"; - if (events[0]) { - if (events[0].data.isError) { - rootSpanStatus = "failed"; - } else if (!events[0].data.isPartial) { - rootSpanStatus = "completed"; - } - } + const reconciled = reconcileTraceWithRunLifecycle( + runData, + traceSummary.rootSpan.id, + events, + totalDuration + ); return { run: runData, trace: { - rootSpanStatus, - events: events, - duration: totalDuration, + rootSpanStatus: reconciled.rootSpanStatus, + events: reconciled.events, + duration: reconciled.totalDuration, rootStartedAt: tree?.data.startTime, startedAt: run.startedAt, queuedDuration: run.startedAt @@ -276,3 +274,66 @@ export class RunPresenter { }; } } + +// NOTE: Clickhouse trace ingestion is eventually consistent. +// When a run is marked finished in Postgres, we reconcile the +// root span to reflect completion even if telemetry is still partial. +// This is a deliberate UI-layer tradeoff to prevent stale or "stuck" +// run states in the dashboard. +export function reconcileTraceWithRunLifecycle( + runData: { + isFinished: boolean; + status: Run["status"]; + createdAt: Date; + completedAt: Date | null; + rootTaskRun: { createdAt: Date } | null; + }, + rootSpanId: string, + events: RunEvent[], + totalDuration: number +): { + events: RunEvent[]; + totalDuration: number; + rootSpanStatus: "executing" | "completed" | "failed"; +} { + const currentStatus: "executing" | "completed" | "failed" = events[0] + ? events[0].data.isError + ? "failed" + : !events[0].data.isPartial + ? "completed" + : "executing" + : "executing"; + + if (!runData.isFinished) { + return { events, totalDuration, rootSpanStatus: currentStatus }; + } + + const postgresRunDuration = runData.completedAt + ? millisecondsToNanoseconds( + runData.completedAt.getTime() - (runData.rootTaskRun?.createdAt ?? runData.createdAt).getTime() + ) + : 0; + + const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); + + const updatedEvents = events.map((e) => { + if (e.id === rootSpanId && e.data.isPartial) { + return { + ...e, + data: { + ...e.data, + isPartial: false, + duration: Math.max(e.data.duration ?? 0, postgresRunDuration), + isError: isFailedRunStatus(runData.status), + }, + }; + } + return e; + }); + + return { + events: updatedEvents, + totalDuration: updatedTotalDuration, + rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + }; +} diff --git a/apps/webapp/test/RunPresenter.test.ts b/apps/webapp/test/RunPresenter.test.ts new file mode 100644 index 0000000000..35fe59e7bd --- /dev/null +++ b/apps/webapp/test/RunPresenter.test.ts @@ -0,0 +1,141 @@ +import { vi, describe, it, expect } from "vitest"; + +vi.mock("../app/env.server", () => ({ + env: { + MAXIMUM_LIVE_RELOADING_EVENTS: 1000, + }, +})); + +vi.mock("../app/db.server", () => ({ + prisma: {}, + $replica: {}, + $transaction: vi.fn(), +})); + +vi.mock("../app/v3/eventRepository/index.server", () => ({ + resolveEventRepositoryForStore: vi.fn(), +})); + +vi.mock("../app/v3/taskEventStore.server", () => ({ + getTaskEventStoreTableForRun: vi.fn(), +})); + +vi.mock("../app/utils/username", () => ({ + getUsername: vi.fn(), +})); + +import { reconcileTraceWithRunLifecycle } from "../app/presenters/v3/RunPresenter.server"; +import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; + +describe("reconcileTraceWithRunLifecycle", () => { + const rootSpanId = "root-span-id"; + const createdAt = new Date("2024-01-01T00:00:00Z"); + const completedAt = new Date("2024-01-01T00:00:05Z"); + + const runData: any = { + isFinished: true, + status: "COMPLETED_SUCCESSFULLY", + createdAt, + completedAt, + rootTaskRun: null, + }; + + const initialEvents = [ + { + id: rootSpanId, + data: { + isPartial: true, + duration: millisecondsToNanoseconds(1000), // 1s, less than the 5s run duration + isError: false, + }, + }, + { + id: "child-span-id", + data: { + isPartial: false, + duration: millisecondsToNanoseconds(500), + isError: false, + }, + }, + ]; + + it("should reconcile a finished run with lagging partial telemetry", () => { + const totalDuration = millisecondsToNanoseconds(1000); + const result = reconcileTraceWithRunLifecycle(runData, rootSpanId, initialEvents as any, totalDuration); + + expect(result.rootSpanStatus).toBe("completed"); + + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.isPartial).toBe(false); + // 5s duration = 5000ms + expect(rootEvent?.data.duration).toBeGreaterThanOrEqual(millisecondsToNanoseconds(5000)); + expect(result.totalDuration).toBeGreaterThanOrEqual(millisecondsToNanoseconds(5000)); + }); + + it("should not override duration if Clickhouse already has a longer finished duration", () => { + const longDuration = millisecondsToNanoseconds(10000); + const finishedEvents = [ + { + id: rootSpanId, + data: { + isPartial: false, + duration: longDuration, + isError: false, + }, + }, + ]; + + const result = reconcileTraceWithRunLifecycle(runData, rootSpanId, finishedEvents as any, longDuration); + + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.duration).toBe(longDuration); + expect(rootEvent?.data.isPartial).toBe(false); + expect(result.totalDuration).toBe(longDuration); + }); + + it("should handle unfinished runs without modification", () => { + const unfinishedRun = { ...runData, isFinished: false, completedAt: null }; + const totalDuration = millisecondsToNanoseconds(1000); + const result = reconcileTraceWithRunLifecycle(unfinishedRun, rootSpanId, initialEvents as any, totalDuration); + + expect(result.rootSpanStatus).toBe("executing"); + + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.isPartial).toBe(true); + expect(rootEvent?.data.duration).toBe(millisecondsToNanoseconds(1000)); + }); + + it("should reconcile failed runs correctly", () => { + const failedRun = { ...runData, status: "COMPLETED_WITH_ERRORS" }; + const result = reconcileTraceWithRunLifecycle(failedRun, rootSpanId, initialEvents as any, millisecondsToNanoseconds(1000)); + + expect(result.rootSpanStatus).toBe("failed"); + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.isError).toBe(true); + expect(rootEvent?.data.isPartial).toBe(false); + }); + + it("should use rootTaskRun createdAt if available for duration calculation", () => { + const rootTaskCreatedAt = new Date("2023-12-31T23:59:50Z"); // 10s before run.createdAt + const runDataWithRoot: any = { + ...runData, + rootTaskRun: { createdAt: rootTaskCreatedAt }, + }; + + const result = reconcileTraceWithRunLifecycle(runDataWithRoot, rootSpanId, initialEvents as any, millisecondsToNanoseconds(1000)); + + // Duration should be from 23:59:50 to 00:00:05 = 15s + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.duration).toBeGreaterThanOrEqual(millisecondsToNanoseconds(15000)); + expect(result.totalDuration).toBeGreaterThanOrEqual(millisecondsToNanoseconds(15000)); + }); + + it("should handle missing root span gracefully", () => { + const result = reconcileTraceWithRunLifecycle(runData, "non-existent-id", initialEvents as any, millisecondsToNanoseconds(1000)); + + expect(result.rootSpanStatus).toBe("completed"); + expect(result.events).toEqual(initialEvents); + // totalDuration should still be updated to postgres duration even if root span is missing from events list + expect(result.totalDuration).toBeGreaterThanOrEqual(millisecondsToNanoseconds(5000)); + }); +}); From 1c67196a9bcd5f6ff09c55fcb8256990860f196b Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 11:53:01 +0530 Subject: [PATCH 03/13] chore(webapp): revert unrelated throttle changes --- apps/webapp/app/utils/throttle.ts | 32 ++++++++++++------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/utils/throttle.ts b/apps/webapp/app/utils/throttle.ts index 24f904fb29..a6c1a77a32 100644 --- a/apps/webapp/app/utils/throttle.ts +++ b/apps/webapp/app/utils/throttle.ts @@ -1,28 +1,20 @@ -/** A throttle that fires the first call immediately and ensures the last call during the duration is also fired. */ +//From: https://kettanaito.com/blog/debounce-vs-throttle + +/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */ export function throttle( func: (...args: any[]) => void, durationMs: number ): (...args: any[]) => void { - let timeoutId: NodeJS.Timeout | null = null; - let nextArgs: any[] | null = null; - - const wrapped = (...args: any[]) => { - if (timeoutId) { - nextArgs = args; - return; - } + let isPrimedToFire = false; - func(...args); + return (...args: any[]) => { + if (!isPrimedToFire) { + isPrimedToFire = true; - timeoutId = setTimeout(() => { - timeoutId = null; - if (nextArgs) { - const argsToUse = nextArgs; - nextArgs = null; - wrapped(...argsToUse); - } - }, durationMs); + setTimeout(() => { + func(...args); + isPrimedToFire = false; + }, durationMs); + } }; - - return wrapped; } From c78797c33814144a69b1b8318a0760a429d00dfb Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 12:04:27 +0530 Subject: [PATCH 04/13] style(webapp): clean up imports and logic in RunPresenter --- apps/webapp/app/presenters/v3/RunPresenter.server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 3ffd8010ee..cb6cd438cc 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -310,7 +310,8 @@ export function reconcileTraceWithRunLifecycle( const postgresRunDuration = runData.completedAt ? millisecondsToNanoseconds( - runData.completedAt.getTime() - (runData.rootTaskRun?.createdAt ?? runData.createdAt).getTime() + runData.completedAt.getTime() - + (runData.rootTaskRun?.createdAt ?? runData.createdAt).getTime() ) : 0; From 5eeb55d78eeaf06fd4d604d0c8a4054e96e3b812 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 12:15:20 +0530 Subject: [PATCH 05/13] fix(webapp): remove root span assumption and fix duplication in RunPresenter --- apps/webapp/app/presenters/v3/RunPresenter.server.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index cb6cd438cc..97f3e9e4ee 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -296,10 +296,11 @@ export function reconcileTraceWithRunLifecycle( totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; } { - const currentStatus: "executing" | "completed" | "failed" = events[0] - ? events[0].data.isError + const rootEvent = events.find((e) => e.id === rootSpanId); + const currentStatus: "executing" | "completed" | "failed" = rootEvent + ? rootEvent.data.isError ? "failed" - : !events[0].data.isPartial + : !rootEvent.data.isPartial ? "completed" : "executing" : "executing"; From 8b140411718969466b4244ef4258dee7bcc790c7 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 13:44:53 +0530 Subject: [PATCH 06/13] fix(webapp): optimize reconciliation to O(1) and add trailing-edge throttle --- .../app/presenters/v3/RunPresenter.server.ts | 105 ++++++++++++------ apps/webapp/app/utils/throttle.ts | 32 ++++-- 2 files changed, 89 insertions(+), 48 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 97f3e9e4ee..596713acb7 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -208,19 +208,43 @@ export class RunPresenter { //we need the start offset for each item, and the total duration of the entire tree const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0; + + const postgresRunDuration = + runData.isFinished && run.completedAt + ? millisecondsToNanoseconds( + run.completedAt.getTime() - + (run.rootTaskRun?.createdAt ?? run.createdAt).getTime() + ) + : 0; + let totalDuration = tree?.data.duration ?? 0; // Build the linkedRunIdBySpanId map during the same walk const linkedRunIdBySpanId: Record = {}; const events = tree - ? flattenTree(tree).map((n) => { - const offset = millisecondsToNanoseconds( - n.data.startTime.getTime() - treeRootStartTimeMs - ); + ? flattenTree(tree).map((n, index) => { + const isRoot = index === 0; + const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs); + + let nIsPartial = n.data.isPartial; + let nDuration = n.data.duration; + let nIsError = n.data.isError; + + // NOTE: Clickhouse trace ingestion is eventually consistent. + // When a run is marked finished in Postgres, we reconcile the + // root span to reflect completion even if telemetry is still partial. + // This is a deliberate UI-layer tradeoff to prevent stale or "stuck" + // run states in the dashboard. + if (isRoot && runData.isFinished && nIsPartial) { + nIsPartial = false; + nDuration = Math.max(nDuration ?? 0, postgresRunDuration); + nIsError = isFailedRunStatus(runData.status); + } + //only let non-debug events extend the total duration if (!n.data.isDebug) { - totalDuration = Math.max(totalDuration, offset + n.data.duration); + totalDuration = Math.max(totalDuration, offset + (nIsPartial ? 0 : nDuration)); } // For cached spans, store the mapping from spanId to the linked run's ID @@ -238,23 +262,24 @@ export class RunPresenter { treeRootStartTimeMs ), //set partial nodes to null duration - duration: n.data.isPartial ? null : n.data.duration, + duration: nIsPartial ? null : nDuration, + isPartial: nIsPartial, + isError: nIsError, offset, - isRoot: n.id === traceSummary.rootSpan.id, + isRoot, }, }; }) : []; + if (runData.isFinished) { + totalDuration = Math.max(totalDuration, postgresRunDuration); + } + //total duration should be a minimum of 1ms totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1)); - const reconciled = reconcileTraceWithRunLifecycle( - runData, - traceSummary.rootSpan.id, - events, - totalDuration - ); + const reconciled = reconcileTraceWithRunLifecycle(runData, traceSummary.rootSpan.id, events, totalDuration); return { run: runData, @@ -296,14 +321,17 @@ export function reconcileTraceWithRunLifecycle( totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; } { - const rootEvent = events.find((e) => e.id === rootSpanId); - const currentStatus: "executing" | "completed" | "failed" = rootEvent - ? rootEvent.data.isError - ? "failed" - : !rootEvent.data.isPartial - ? "completed" - : "executing" - : "executing"; + const rootEvent = events[0]; + const isActualRoot = rootEvent?.id === rootSpanId; + + const currentStatus: "executing" | "completed" | "failed" = + isActualRoot && rootEvent + ? rootEvent.data.isError + ? "failed" + : !rootEvent.data.isPartial + ? "completed" + : "executing" + : "executing"; if (!runData.isFinished) { return { events, totalDuration, rootSpanStatus: currentStatus }; @@ -318,23 +346,28 @@ export function reconcileTraceWithRunLifecycle( const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); - const updatedEvents = events.map((e) => { - if (e.id === rootSpanId && e.data.isPartial) { - return { - ...e, - data: { - ...e.data, - isPartial: false, - duration: Math.max(e.data.duration ?? 0, postgresRunDuration), - isError: isFailedRunStatus(runData.status), - }, - }; - } - return e; - }); + // We only need to potentially update the root event (the first one) if it matches our ID + if (isActualRoot && rootEvent && rootEvent.data.isPartial) { + const updatedEvents = [...events]; + updatedEvents[0] = { + ...rootEvent, + data: { + ...rootEvent.data, + isPartial: false, + duration: Math.max(rootEvent.data.duration ?? 0, postgresRunDuration), + isError: isFailedRunStatus(runData.status), + }, + }; + + return { + events: updatedEvents, + totalDuration: updatedTotalDuration, + rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + }; + } return { - events: updatedEvents, + events, totalDuration: updatedTotalDuration, rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", }; diff --git a/apps/webapp/app/utils/throttle.ts b/apps/webapp/app/utils/throttle.ts index a6c1a77a32..24f904fb29 100644 --- a/apps/webapp/app/utils/throttle.ts +++ b/apps/webapp/app/utils/throttle.ts @@ -1,20 +1,28 @@ -//From: https://kettanaito.com/blog/debounce-vs-throttle - -/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */ +/** A throttle that fires the first call immediately and ensures the last call during the duration is also fired. */ export function throttle( func: (...args: any[]) => void, durationMs: number ): (...args: any[]) => void { - let isPrimedToFire = false; - - return (...args: any[]) => { - if (!isPrimedToFire) { - isPrimedToFire = true; + let timeoutId: NodeJS.Timeout | null = null; + let nextArgs: any[] | null = null; - setTimeout(() => { - func(...args); - isPrimedToFire = false; - }, durationMs); + const wrapped = (...args: any[]) => { + if (timeoutId) { + nextArgs = args; + return; } + + func(...args); + + timeoutId = setTimeout(() => { + timeoutId = null; + if (nextArgs) { + const argsToUse = nextArgs; + nextArgs = null; + wrapped(...argsToUse); + } + }, durationMs); }; + + return wrapped; } From c02754537202425b00a265fa7f754d72d51c6c01 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 13:50:18 +0530 Subject: [PATCH 07/13] fix(webapp): add missing createdAt to runData for accurate duration reconciliation --- apps/webapp/app/presenters/v3/RunPresenter.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 596713acb7..7f57d91e41 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -124,6 +124,7 @@ export class RunPresenter { isFinished: isFinalRunStatus(run.status), startedAt: run.startedAt, completedAt: run.completedAt, + createdAt: run.createdAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, parentTaskRun: run.parentTaskRun, From 43897e31914343457554c14bcc2450f4ae89993c Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 16:00:34 +0530 Subject: [PATCH 08/13] refactor(webapp): move reconciliation logic to separate file for better testability --- .../app/presenters/v3/RunPresenter.server.ts | 106 ++++++------------ .../presenters/v3/reconcileTrace.server.ts | 89 +++++++++++++++ 2 files changed, 125 insertions(+), 70 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/reconcileTrace.server.ts diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 7f57d91e41..97f3e9e4ee 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -124,7 +124,6 @@ export class RunPresenter { isFinished: isFinalRunStatus(run.status), startedAt: run.startedAt, completedAt: run.completedAt, - createdAt: run.createdAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, parentTaskRun: run.parentTaskRun, @@ -209,43 +208,19 @@ export class RunPresenter { //we need the start offset for each item, and the total duration of the entire tree const treeRootStartTimeMs = tree ? tree?.data.startTime.getTime() : 0; - - const postgresRunDuration = - runData.isFinished && run.completedAt - ? millisecondsToNanoseconds( - run.completedAt.getTime() - - (run.rootTaskRun?.createdAt ?? run.createdAt).getTime() - ) - : 0; - let totalDuration = tree?.data.duration ?? 0; // Build the linkedRunIdBySpanId map during the same walk const linkedRunIdBySpanId: Record = {}; const events = tree - ? flattenTree(tree).map((n, index) => { - const isRoot = index === 0; - const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs); - - let nIsPartial = n.data.isPartial; - let nDuration = n.data.duration; - let nIsError = n.data.isError; - - // NOTE: Clickhouse trace ingestion is eventually consistent. - // When a run is marked finished in Postgres, we reconcile the - // root span to reflect completion even if telemetry is still partial. - // This is a deliberate UI-layer tradeoff to prevent stale or "stuck" - // run states in the dashboard. - if (isRoot && runData.isFinished && nIsPartial) { - nIsPartial = false; - nDuration = Math.max(nDuration ?? 0, postgresRunDuration); - nIsError = isFailedRunStatus(runData.status); - } - + ? flattenTree(tree).map((n) => { + const offset = millisecondsToNanoseconds( + n.data.startTime.getTime() - treeRootStartTimeMs + ); //only let non-debug events extend the total duration if (!n.data.isDebug) { - totalDuration = Math.max(totalDuration, offset + (nIsPartial ? 0 : nDuration)); + totalDuration = Math.max(totalDuration, offset + n.data.duration); } // For cached spans, store the mapping from spanId to the linked run's ID @@ -263,24 +238,23 @@ export class RunPresenter { treeRootStartTimeMs ), //set partial nodes to null duration - duration: nIsPartial ? null : nDuration, - isPartial: nIsPartial, - isError: nIsError, + duration: n.data.isPartial ? null : n.data.duration, offset, - isRoot, + isRoot: n.id === traceSummary.rootSpan.id, }, }; }) : []; - if (runData.isFinished) { - totalDuration = Math.max(totalDuration, postgresRunDuration); - } - //total duration should be a minimum of 1ms totalDuration = Math.max(totalDuration, millisecondsToNanoseconds(1)); - const reconciled = reconcileTraceWithRunLifecycle(runData, traceSummary.rootSpan.id, events, totalDuration); + const reconciled = reconcileTraceWithRunLifecycle( + runData, + traceSummary.rootSpan.id, + events, + totalDuration + ); return { run: runData, @@ -322,17 +296,14 @@ export function reconcileTraceWithRunLifecycle( totalDuration: number; rootSpanStatus: "executing" | "completed" | "failed"; } { - const rootEvent = events[0]; - const isActualRoot = rootEvent?.id === rootSpanId; - - const currentStatus: "executing" | "completed" | "failed" = - isActualRoot && rootEvent - ? rootEvent.data.isError - ? "failed" - : !rootEvent.data.isPartial - ? "completed" - : "executing" - : "executing"; + const rootEvent = events.find((e) => e.id === rootSpanId); + const currentStatus: "executing" | "completed" | "failed" = rootEvent + ? rootEvent.data.isError + ? "failed" + : !rootEvent.data.isPartial + ? "completed" + : "executing" + : "executing"; if (!runData.isFinished) { return { events, totalDuration, rootSpanStatus: currentStatus }; @@ -347,28 +318,23 @@ export function reconcileTraceWithRunLifecycle( const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); - // We only need to potentially update the root event (the first one) if it matches our ID - if (isActualRoot && rootEvent && rootEvent.data.isPartial) { - const updatedEvents = [...events]; - updatedEvents[0] = { - ...rootEvent, - data: { - ...rootEvent.data, - isPartial: false, - duration: Math.max(rootEvent.data.duration ?? 0, postgresRunDuration), - isError: isFailedRunStatus(runData.status), - }, - }; - - return { - events: updatedEvents, - totalDuration: updatedTotalDuration, - rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", - }; - } + const updatedEvents = events.map((e) => { + if (e.id === rootSpanId && e.data.isPartial) { + return { + ...e, + data: { + ...e.data, + isPartial: false, + duration: Math.max(e.data.duration ?? 0, postgresRunDuration), + isError: isFailedRunStatus(runData.status), + }, + }; + } + return e; + }); return { - events, + events: updatedEvents, totalDuration: updatedTotalDuration, rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", }; diff --git a/apps/webapp/app/presenters/v3/reconcileTrace.server.ts b/apps/webapp/app/presenters/v3/reconcileTrace.server.ts new file mode 100644 index 0000000000..dc2e2ba565 --- /dev/null +++ b/apps/webapp/app/presenters/v3/reconcileTrace.server.ts @@ -0,0 +1,89 @@ +import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; +import { isFailedRunStatus } from "~/v3/taskStatus"; +import type { TaskRunStatus } from "@trigger.dev/database"; + +export type ReconcileRunData = { + isFinished: boolean; + status: TaskRunStatus; + createdAt: Date; + completedAt: Date | null; + rootTaskRun: { createdAt: Date } | null; +}; + +export type ReconcileEvent = { + id: string; + data: { + isPartial: boolean; + isError: boolean; + duration?: number | null; + }; +}; + +export type ReconcileResult = { + events: any[]; + totalDuration: number; + rootSpanStatus: "executing" | "completed" | "failed"; +}; + +// NOTE: Clickhouse trace ingestion is eventually consistent. +// When a run is marked finished in Postgres, we reconcile the +// root span to reflect completion even if telemetry is still partial. +// This is a deliberate UI-layer tradeoff to prevent stale or "stuck" +// run states in the dashboard. +export function reconcileTraceWithRunLifecycle( + runData: ReconcileRunData, + rootSpanId: string, + events: any[], + totalDuration: number +): ReconcileResult { + const rootEvent = events[0]; + const isActualRoot = rootEvent?.id === rootSpanId; + + const currentStatus: "executing" | "completed" | "failed" = + isActualRoot && rootEvent + ? rootEvent.data.isError + ? "failed" + : !rootEvent.data.isPartial + ? "completed" + : "executing" + : "executing"; + + if (!runData.isFinished) { + return { events, totalDuration, rootSpanStatus: currentStatus }; + } + + const postgresRunDuration = runData.completedAt + ? millisecondsToNanoseconds( + runData.completedAt.getTime() - + (runData.rootTaskRun?.createdAt ?? runData.createdAt).getTime() + ) + : 0; + + const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); + + // We only need to potentially update the root event (the first one) if it matches our ID + if (isActualRoot && rootEvent && rootEvent.data.isPartial) { + const updatedEvents = [...events]; + updatedEvents[0] = { + ...rootEvent, + data: { + ...rootEvent.data, + isPartial: false, + duration: Math.max(rootEvent.data.duration ?? 0, postgresRunDuration), + isError: isFailedRunStatus(runData.status), + }, + }; + + return { + events: updatedEvents, + totalDuration: updatedTotalDuration, + rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + }; + } + + return { + events, + totalDuration: updatedTotalDuration, + rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + }; +} From 9500f2e7400a04a8c74ae80828dc1419358abdc7 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 16:15:27 +0530 Subject: [PATCH 09/13] refactor(webapp): complete modularization of reconciliation logic --- .../app/presenters/v3/RunPresenter.server.ts | 97 +++++-------------- apps/webapp/test/RunPresenter.test.ts | 2 +- 2 files changed, 27 insertions(+), 72 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 97f3e9e4ee..31f335c9df 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -8,6 +8,7 @@ import { SpanSummary } from "~/v3/eventRepository/eventRepository.types"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { env } from "~/env.server"; +import { reconcileTraceWithRunLifecycle } from "./reconcileTrace.server"; type Result = Awaited>; export type Run = Result["run"]; @@ -124,6 +125,7 @@ export class RunPresenter { isFinished: isFinalRunStatus(run.status), startedAt: run.startedAt, completedAt: run.completedAt, + createdAt: run.createdAt, logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt, rootTaskRun: run.rootTaskRun, parentTaskRun: run.parentTaskRun, @@ -214,13 +216,28 @@ export class RunPresenter { const linkedRunIdBySpanId: Record = {}; const events = tree - ? flattenTree(tree).map((n) => { - const offset = millisecondsToNanoseconds( - n.data.startTime.getTime() - treeRootStartTimeMs - ); + ? flattenTree(tree).map((n, index) => { + const isRoot = index === 0; + const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs); + + let nIsPartial = n.data.isPartial; + let nDuration = n.data.duration; + let nIsError = n.data.isError; + + // NOTE: Clickhouse trace ingestion is eventually consistent. + // When a run is marked finished in Postgres, we reconcile the + // root span to reflect completion even if telemetry is still partial. + // This is a deliberate UI-layer tradeoff to prevent stale or "stuck" + // run states in the dashboard. + if (isRoot && runData.isFinished && nIsPartial) { + nIsPartial = false; + nDuration = Math.max(nDuration ?? 0, postgresRunDuration); + nIsError = isFailedRunStatus(runData.status); + } + //only let non-debug events extend the total duration if (!n.data.isDebug) { - totalDuration = Math.max(totalDuration, offset + n.data.duration); + totalDuration = Math.max(totalDuration, offset + (nIsPartial ? 0 : nDuration)); } // For cached spans, store the mapping from spanId to the linked run's ID @@ -238,9 +255,11 @@ export class RunPresenter { treeRootStartTimeMs ), //set partial nodes to null duration - duration: n.data.isPartial ? null : n.data.duration, + duration: nIsPartial ? null : nDuration, + isPartial: nIsPartial, + isError: nIsError, offset, - isRoot: n.id === traceSummary.rootSpan.id, + isRoot, }, }; }) @@ -275,67 +294,3 @@ export class RunPresenter { } } -// NOTE: Clickhouse trace ingestion is eventually consistent. -// When a run is marked finished in Postgres, we reconcile the -// root span to reflect completion even if telemetry is still partial. -// This is a deliberate UI-layer tradeoff to prevent stale or "stuck" -// run states in the dashboard. -export function reconcileTraceWithRunLifecycle( - runData: { - isFinished: boolean; - status: Run["status"]; - createdAt: Date; - completedAt: Date | null; - rootTaskRun: { createdAt: Date } | null; - }, - rootSpanId: string, - events: RunEvent[], - totalDuration: number -): { - events: RunEvent[]; - totalDuration: number; - rootSpanStatus: "executing" | "completed" | "failed"; -} { - const rootEvent = events.find((e) => e.id === rootSpanId); - const currentStatus: "executing" | "completed" | "failed" = rootEvent - ? rootEvent.data.isError - ? "failed" - : !rootEvent.data.isPartial - ? "completed" - : "executing" - : "executing"; - - if (!runData.isFinished) { - return { events, totalDuration, rootSpanStatus: currentStatus }; - } - - const postgresRunDuration = runData.completedAt - ? millisecondsToNanoseconds( - runData.completedAt.getTime() - - (runData.rootTaskRun?.createdAt ?? runData.createdAt).getTime() - ) - : 0; - - const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); - - const updatedEvents = events.map((e) => { - if (e.id === rootSpanId && e.data.isPartial) { - return { - ...e, - data: { - ...e.data, - isPartial: false, - duration: Math.max(e.data.duration ?? 0, postgresRunDuration), - isError: isFailedRunStatus(runData.status), - }, - }; - } - return e; - }); - - return { - events: updatedEvents, - totalDuration: updatedTotalDuration, - rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", - }; -} diff --git a/apps/webapp/test/RunPresenter.test.ts b/apps/webapp/test/RunPresenter.test.ts index 35fe59e7bd..15d459487a 100644 --- a/apps/webapp/test/RunPresenter.test.ts +++ b/apps/webapp/test/RunPresenter.test.ts @@ -24,7 +24,7 @@ vi.mock("../app/utils/username", () => ({ getUsername: vi.fn(), })); -import { reconcileTraceWithRunLifecycle } from "../app/presenters/v3/RunPresenter.server"; +import { reconcileTraceWithRunLifecycle } from "../app/presenters/v3/reconcileTrace.server"; import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; describe("reconcileTraceWithRunLifecycle", () => { From f15bf5761a809f29e674d5251a77cc8de1ac570b Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 16:32:16 +0530 Subject: [PATCH 10/13] refactor(webapp): modularize reconciliation logic and move tests to conventional location --- .../app/presenters/v3/RunPresenter.server.ts | 10 ------- .../v3/reconcileTrace.server.test.ts} | 29 ++----------------- apps/webapp/vitest.config.ts | 2 +- 3 files changed, 3 insertions(+), 38 deletions(-) rename apps/webapp/{test/RunPresenter.test.ts => app/presenters/v3/reconcileTrace.server.test.ts} (88%) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 31f335c9df..6cdcb6960b 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -224,16 +224,6 @@ export class RunPresenter { let nDuration = n.data.duration; let nIsError = n.data.isError; - // NOTE: Clickhouse trace ingestion is eventually consistent. - // When a run is marked finished in Postgres, we reconcile the - // root span to reflect completion even if telemetry is still partial. - // This is a deliberate UI-layer tradeoff to prevent stale or "stuck" - // run states in the dashboard. - if (isRoot && runData.isFinished && nIsPartial) { - nIsPartial = false; - nDuration = Math.max(nDuration ?? 0, postgresRunDuration); - nIsError = isFailedRunStatus(runData.status); - } //only let non-debug events extend the total duration if (!n.data.isDebug) { diff --git a/apps/webapp/test/RunPresenter.test.ts b/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts similarity index 88% rename from apps/webapp/test/RunPresenter.test.ts rename to apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts index 15d459487a..9f5eca11e7 100644 --- a/apps/webapp/test/RunPresenter.test.ts +++ b/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts @@ -1,30 +1,5 @@ -import { vi, describe, it, expect } from "vitest"; - -vi.mock("../app/env.server", () => ({ - env: { - MAXIMUM_LIVE_RELOADING_EVENTS: 1000, - }, -})); - -vi.mock("../app/db.server", () => ({ - prisma: {}, - $replica: {}, - $transaction: vi.fn(), -})); - -vi.mock("../app/v3/eventRepository/index.server", () => ({ - resolveEventRepositoryForStore: vi.fn(), -})); - -vi.mock("../app/v3/taskEventStore.server", () => ({ - getTaskEventStoreTableForRun: vi.fn(), -})); - -vi.mock("../app/utils/username", () => ({ - getUsername: vi.fn(), -})); - -import { reconcileTraceWithRunLifecycle } from "../app/presenters/v3/reconcileTrace.server"; +import { describe, it, expect } from "vitest"; +import { reconcileTraceWithRunLifecycle } from "./reconcileTrace.server"; import { millisecondsToNanoseconds } from "@trigger.dev/core/v3"; describe("reconcileTraceWithRunLifecycle", () => { diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 0c08af40ea..ae86814e97 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -3,7 +3,7 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ test: { - include: ["test/**/*.test.ts"], + include: ["test/**/*.test.ts", "app/**/*.test.ts"], globals: true, pool: "forks", }, From 630f53983d25816926e94df47697e27f1879ad4f Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Tue, 13 Jan 2026 16:38:00 +0530 Subject: [PATCH 11/13] refactor(webapp): remove unused import in RunPresenter.server.ts --- apps/webapp/app/presenters/v3/RunPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 6cdcb6960b..6ca2f5d944 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -6,7 +6,7 @@ import { getUsername } from "~/utils/username"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { SpanSummary } from "~/v3/eventRepository/eventRepository.types"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; -import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; +import { isFinalRunStatus } from "~/v3/taskStatus"; import { env } from "~/env.server"; import { reconcileTraceWithRunLifecycle } from "./reconcileTrace.server"; From 60208c889e63b74918a8b8634b406feef4997c45 Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Sun, 25 Jan 2026 12:05:47 +0530 Subject: [PATCH 12/13] fix(webapp): treat EXPIRED runs as failed in trace reconciliation --- .../app/presenters/v3/reconcileTrace.server.test.ts | 10 ++++++++++ apps/webapp/app/presenters/v3/reconcileTrace.server.ts | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts b/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts index 9f5eca11e7..9b8ec862d1 100644 --- a/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts +++ b/apps/webapp/app/presenters/v3/reconcileTrace.server.test.ts @@ -90,6 +90,16 @@ describe("reconcileTraceWithRunLifecycle", () => { expect(rootEvent?.data.isPartial).toBe(false); }); + it("should reconcile expired runs correctly", () => { + const expiredRun = { ...runData, status: "EXPIRED" }; + const result = reconcileTraceWithRunLifecycle(expiredRun, rootSpanId, initialEvents as any, millisecondsToNanoseconds(1000)); + + expect(result.rootSpanStatus).toBe("failed"); + const rootEvent = result.events.find((e: any) => e.id === rootSpanId); + expect(rootEvent?.data.isError).toBe(true); + expect(rootEvent?.data.isPartial).toBe(false); + }); + it("should use rootTaskRun createdAt if available for duration calculation", () => { const rootTaskCreatedAt = new Date("2023-12-31T23:59:50Z"); // 10s before run.createdAt const runDataWithRoot: any = { diff --git a/apps/webapp/app/presenters/v3/reconcileTrace.server.ts b/apps/webapp/app/presenters/v3/reconcileTrace.server.ts index dc2e2ba565..05edd2f112 100644 --- a/apps/webapp/app/presenters/v3/reconcileTrace.server.ts +++ b/apps/webapp/app/presenters/v3/reconcileTrace.server.ts @@ -61,6 +61,8 @@ export function reconcileTraceWithRunLifecycle( const updatedTotalDuration = Math.max(totalDuration, postgresRunDuration); + const isError = isFailedRunStatus(runData.status) || runData.status === "EXPIRED"; + // We only need to potentially update the root event (the first one) if it matches our ID if (isActualRoot && rootEvent && rootEvent.data.isPartial) { const updatedEvents = [...events]; @@ -70,20 +72,20 @@ export function reconcileTraceWithRunLifecycle( ...rootEvent.data, isPartial: false, duration: Math.max(rootEvent.data.duration ?? 0, postgresRunDuration), - isError: isFailedRunStatus(runData.status), + isError, }, }; return { events: updatedEvents, totalDuration: updatedTotalDuration, - rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + rootSpanStatus: isError ? "failed" : "completed", }; } return { events, totalDuration: updatedTotalDuration, - rootSpanStatus: isFailedRunStatus(runData.status) ? "failed" : "completed", + rootSpanStatus: isError ? "failed" : "completed", }; } From d15212180d277b7570b7d33da0b551329658f4ed Mon Sep 17 00:00:00 2001 From: bharathkumar39293 Date: Wed, 28 Jan 2026 12:15:27 +0530 Subject: [PATCH 13/13] fix(webapp): preserve data integrity for non-JSON payloads in replay This fix removes the unintended Zod transformation and JSON enforcement in the ReplayRunData schema. It also refines the UI editability rules to be storage-based (not MIME-based) and adds a 512KB safety threshold for browser performance. --- .../components/runs/v3/ReplayRunDialog.tsx | 23 +++++++++++-------- apps/webapp/app/v3/replayTask.ts | 20 +--------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 9192020e1b..3493b752ca 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -88,8 +88,7 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) } queueFetcher.load( - `/resources/orgs/${params.organizationSlug}/projects/${ - params.projectParam + `/resources/orgs/${params.organizationSlug}/projects/${params.projectParam }/env/${envSlug}/queues?${searchParams.toString()}` ); } @@ -163,9 +162,9 @@ function ReplayForm({ const isSubmitting = navigation.formAction === formAction; - const editablePayload = - replayData.payloadType === "application/json" || - replayData.payloadType === "application/super+json"; + const isLargePayload = replayData.payloadType === "application/store"; + const isTooLargeToEdit = (replayData.payload?.length ?? 0) > 512 * 1024; + const editablePayload = !isLargePayload && !isTooLargeToEdit; const [tab, setTab] = useState<"payload" | "metadata">(editablePayload ? "payload" : "metadata"); @@ -275,10 +274,16 @@ function ReplayForm({ - Payload is not editable for runs with{" "} - - large payloads. - + {isLargePayload ? ( + <> + Payload is not editable for runs with{" "} + + large payloads. + + + ) : ( + "Payload is too large to edit." + )} } /> diff --git a/apps/webapp/app/v3/replayTask.ts b/apps/webapp/app/v3/replayTask.ts index 90897cf7c2..6ee6ced33d 100644 --- a/apps/webapp/app/v3/replayTask.ts +++ b/apps/webapp/app/v3/replayTask.ts @@ -4,25 +4,7 @@ import { RunOptionsData } from "./testTask"; export const ReplayRunData = z .object({ environment: z.string().optional(), - payload: z - .string() - .optional() - .transform((val, ctx) => { - if (!val) { - return "{}"; - } - - try { - JSON.parse(val); - return val; - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Payload must be a valid JSON string", - }); - return z.NEVER; - } - }), + payload: z.string().optional(), metadata: z .string() .optional()