Skip to content

Commit 50dcb33

Browse files
d-csclaude
andcommitted
fix(webapp): review fixes for mollifier dashboard parity
Address review findings on PR #3757: - replay: guard `synthetic.traceId` / `spanId` before adapting a buffered snapshot into the TaskRun shape ReplayTaskRunService expects. Without the guard, an older snapshot missing those fields produces `00-undefined-undefined-01` as the W3C traceparent, which OTel silently drops — severing the replayed run's trace linkage. Extract the adapter into `buildSyntheticReplayTaskRun` so the guard is unit tested. - run-detail: reflect the buffered snapshot's CANCELED state into the NavBar status + Cancel-button gate. Previously `tryMollifiedRunFallback` hardcoded `status: PENDING` / `isFinished: false` regardless of `buffered.status` / `cancelledAt`, so cancelling a buffered run left the NavBar showing Pending with a Cancel button until the drainer materialised the row. Extract the mapping into `buildSyntheticRunHeader` and unit test PENDING vs CANCELED. - admin runs redirect (@.runs.$runParam): preselect the root span on the buffered redirect via `v3RunSpanPath`, matching the sibling redirect routes. Without this the trace tree opened with nothing selected for buffered runs reached via the admin impersonate path. - RunStreamPresenter: switch from `deserialiseSnapshot` (redis-worker) to the webapp wrapper `deserialiseMollifierSnapshot` so both read-side modules share one deserialisation path, per the contract comment in `syntheticRedirectInfo.server.ts`. - cancel route: collapse the `mutateSnapshot` "not_found" branch into the same retry message as "busy". Both indicate the drainer raced the mutation between `getEntry` and `mutateSnapshot`; the run is in PG by then and a retry hits the regular cancel path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28112c4 commit 50dcb33

9 files changed

Lines changed: 344 additions & 45 deletions

File tree

apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { singleton } from "~/utils/singleton";
44
import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse";
55
import { throttle } from "~/utils/throttle";
66
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
7-
import { deserialiseSnapshot } from "@trigger.dev/redis-worker";
7+
import { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
88
import { tracePubSub } from "~/v3/services/tracePubSub.server";
99

1010
const PING_INTERVAL = 5_000;
@@ -52,7 +52,10 @@ export class RunStreamPresenter {
5252
try {
5353
const entry = await buffer.getEntry(runFriendlyId);
5454
if (entry) {
55-
const snapshot = deserialiseSnapshot<{ traceId?: string }>(entry.payload);
55+
// Go through the webapp wrapper so this read-side module
56+
// shares a single deserialisation path with readFallback —
57+
// see the contract comment in syntheticRedirectInfo.server.ts.
58+
const snapshot = deserialiseMollifierSnapshot(entry.payload);
5659
if (typeof snapshot.traceId === "string") {
5760
traceId = snapshot.traceId;
5861
}

apps/webapp/app/routes/@.runs.$runParam.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from "zod";
33
import { prisma } from "~/db.server";
44
import { redirectWithErrorMessage } from "~/models/message.server";
55
import { requireUser } from "~/services/session.server";
6-
import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder";
6+
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
77
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
88

99
const ParamsSchema = z.object({
@@ -33,6 +33,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
3333
friendlyId: runParam,
3434
},
3535
select: {
36+
spanId: true,
3637
runtimeEnvironment: {
3738
select: {
3839
slug: true,
@@ -61,27 +62,36 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
6162
skipOrgMembershipCheck: true,
6263
});
6364
if (buffered) {
64-
return redirect(
65-
impersonate(
66-
v3RunPath(
65+
// Preselect the root span so the run-detail trace tree opens with
66+
// the buffered run's span highlighted, matching the sibling
67+
// redirect routes (runs.$runParam.ts, projects.v3.$projectRef…).
68+
const path = buffered.spanId
69+
? v3RunSpanPath(
6770
{ slug: buffered.organizationSlug },
6871
{ slug: buffered.projectSlug },
6972
{ slug: buffered.environmentSlug },
70-
{ friendlyId: runParam }
73+
{ friendlyId: runParam },
74+
{ spanId: buffered.spanId }
7175
)
72-
)
73-
);
76+
: v3RunPath(
77+
{ slug: buffered.organizationSlug },
78+
{ slug: buffered.projectSlug },
79+
{ slug: buffered.environmentSlug },
80+
{ friendlyId: runParam }
81+
);
82+
return redirect(impersonate(path));
7483
}
7584
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
7685
ephemeral: false,
7786
});
7887
}
7988

80-
const path = v3RunPath(
89+
const path = v3RunSpanPath(
8190
{ slug: run.project.organization.slug },
8291
{ slug: run.project.slug },
8392
{ slug: run.runtimeEnvironment.slug },
84-
{ friendlyId: runParam }
93+
{ friendlyId: runParam },
94+
{ spanId: run.spanId }
8595
);
8696

8797
return redirect(impersonate(path));

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
9494
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
9595
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
9696
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
97+
import { buildSyntheticRunHeader } from "~/v3/mollifier/syntheticRunHeader.server";
9798
import { buildSyntheticTraceForBufferedRun } from "~/v3/mollifier/syntheticTrace.server";
9899
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
99100
import { getImpersonationId } from "~/services/impersonation.server";
@@ -353,28 +354,15 @@ async function tryMollifiedRunFallback(args: {
353354
if (!buffered) return null;
354355

355356
return {
356-
run: {
357-
id: buffered.friendlyId,
358-
number: 1,
359-
friendlyId: buffered.friendlyId,
360-
traceId: buffered.traceId ?? "",
361-
spanId: buffered.spanId ?? "",
362-
status: "PENDING" as const,
363-
isFinished: false,
364-
startedAt: null,
365-
completedAt: null,
366-
logsDeletedAt: null,
367-
rootTaskRun: null,
368-
parentTaskRun: null,
357+
run: buildSyntheticRunHeader({
358+
run: buffered,
369359
environment: {
370360
id: environment.id,
371361
organizationId: project.organizationId,
372362
type: environment.type,
373363
slug: environment.slug,
374-
userId: undefined,
375-
userName: undefined,
376364
},
377-
},
365+
}),
378366
trace: buildSyntheticTraceForBufferedRun(buffered),
379367
};
380368
}

apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ export const action: ActionFunction = async ({ request, params }) => {
8282
if (result === "applied_to_snapshot") {
8383
return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
8484
}
85-
if (result === "not_found") {
86-
submission.error = { runParam: ["Run not found"] };
87-
return json(submission);
88-
}
89-
// "busy"drainer is materialising. Customer can retry; by then the
90-
// PG row exists and the regular cancel path takes over.
85+
// "not_found" or "busy" — both indicate the drainer raced us between
86+
// the getEntry check above and mutateSnapshot. On "not_found" the
87+
// entry was just popped and the PG row is in flight; on "busy" the
88+
// drainer is mid-materialisation. Either way the customer should
89+
// retryby then the PG row exists and the regular cancel path at
90+
// the top of this action takes over.
9191
return redirectWithErrorMessage(
9292
submission.value.redirectUrl,
9393
request,

apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { v3RunSpanPath } from "~/utils/pathBuilder";
1313
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
1414
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
1515
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
16-
import type { TaskRun } from "@trigger.dev/database";
16+
import {
17+
buildSyntheticReplayTaskRun,
18+
type SyntheticReplayTaskRun,
19+
} from "~/v3/mollifier/syntheticReplayTaskRun.server";
1720
import parseDuration from "parse-duration";
1821
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
1922
import { queueTypeFromType } from "~/presenters/v3/QueueRetrievePresenter.server";
@@ -268,12 +271,7 @@ export const action: ActionFunction = async ({ request, params }) => {
268271
// SyntheticRun carries every field ReplayTaskRunService reads. We
269272
// also need projectSlug + orgSlug + envSlug for the redirect path,
270273
// so look those up via the snapshot's runtimeEnvironmentId.
271-
let taskRun:
272-
| (TaskRun & {
273-
project: { slug: string; organization: { slug: string } };
274-
runtimeEnvironment: { slug: string };
275-
})
276-
| null = pgRun ?? null;
274+
let taskRun: SyntheticReplayTaskRun | null = pgRun ?? null;
277275
if (!taskRun) {
278276
const buffer = getMollifierBuffer();
279277
const entry = buffer ? await buffer.getEntry(runParam) : null;
@@ -292,11 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
292290
},
293291
});
294292
if (envRow) {
295-
taskRun = {
296-
...(synthetic as unknown as TaskRun),
297-
project: { slug: envRow.project.slug, organization: { slug: envRow.project.organization.slug } },
298-
runtimeEnvironment: { slug: envRow.slug },
299-
};
293+
taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow });
300294
}
301295
}
302296
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { TaskRun } from "@trigger.dev/database";
2+
import type { SyntheticRun } from "./readFallback.server";
3+
4+
export type SyntheticReplayTaskRun = TaskRun & {
5+
project: { slug: string; organization: { slug: string } };
6+
runtimeEnvironment: { slug: string };
7+
};
8+
9+
// Adapt a buffered-run snapshot into the TaskRun-shaped input that
10+
// `ReplayTaskRunService.call` expects. ReplayTaskRunService builds the
11+
// new run's traceparent as `00-${existingTaskRun.traceId}-${existingTaskRun.spanId}-01`
12+
// without guarding for undefined, so a synthetic with missing traceId
13+
// or spanId (older snapshots — both fields are documented optional on
14+
// `SyntheticRun`) would produce `00-undefined-undefined-01`, an invalid
15+
// W3C traceparent that OTel silently drops, severing the replay's trace
16+
// link to the original run.
17+
//
18+
// Returns null when those fields are missing — the caller surfaces this
19+
// as "Run not found" so the customer retries once the drainer has
20+
// materialised the PG row, where traceId/spanId are guaranteed present.
21+
export function buildSyntheticReplayTaskRun(args: {
22+
synthetic: SyntheticRun;
23+
envRow: {
24+
slug: string;
25+
project: { slug: string; organization: { slug: string } };
26+
};
27+
}): SyntheticReplayTaskRun | null {
28+
const { synthetic, envRow } = args;
29+
if (!synthetic.traceId || !synthetic.spanId) return null;
30+
return {
31+
...(synthetic as unknown as TaskRun),
32+
project: {
33+
slug: envRow.project.slug,
34+
organization: { slug: envRow.project.organization.slug },
35+
},
36+
runtimeEnvironment: { slug: envRow.slug },
37+
};
38+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { SyntheticRun } from "./readFallback.server";
2+
3+
// Synthesise the run-detail page's `run` header shape (the NavBar +
4+
// status badge + Cancel-button gate) from a buffered run snapshot. The
5+
// shape matches `RunPresenter.getRun`'s `runData` — keep this in sync
6+
// when fields are added there.
7+
//
8+
// CANCELED state is reflected back from `SyntheticRun.cancelledAt` /
9+
// `status` so that after a buffered-cancel the NavBar shows the run as
10+
// CANCELED + isFinished:true (which collapses the Cancel button) before
11+
// the drainer materialises the PG row. This mirrors what
12+
// `buildSyntheticSpanRun` does for the right-side details panel — the
13+
// SyntheticRun.cancelledAt contract comment in readFallback.server.ts
14+
// names this exact UI surface.
15+
export function buildSyntheticRunHeader(args: {
16+
run: SyntheticRun;
17+
environment: {
18+
id: string;
19+
organizationId: string;
20+
type: "PRODUCTION" | "DEVELOPMENT" | "STAGING" | "PREVIEW";
21+
slug: string;
22+
};
23+
}) {
24+
const { run, environment } = args;
25+
const isCancelled = run.status === "CANCELED";
26+
27+
return {
28+
id: run.friendlyId,
29+
number: 1,
30+
friendlyId: run.friendlyId,
31+
traceId: run.traceId ?? "",
32+
spanId: run.spanId ?? "",
33+
status: isCancelled ? ("CANCELED" as const) : ("PENDING" as const),
34+
isFinished: isCancelled,
35+
startedAt: null,
36+
completedAt: run.cancelledAt ?? null,
37+
logsDeletedAt: null,
38+
rootTaskRun: null,
39+
parentTaskRun: null,
40+
environment: {
41+
id: environment.id,
42+
organizationId: environment.organizationId,
43+
type: environment.type,
44+
slug: environment.slug,
45+
userId: undefined,
46+
userName: undefined,
47+
},
48+
};
49+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
4+
5+
import { buildSyntheticReplayTaskRun } from "~/v3/mollifier/syntheticReplayTaskRun.server";
6+
import type { SyntheticRun } from "~/v3/mollifier/readFallback.server";
7+
8+
const NOW = new Date("2026-05-21T10:00:00Z");
9+
10+
function makeSyntheticRun(overrides: Partial<SyntheticRun> = {}): SyntheticRun {
11+
return {
12+
id: "run_internal_1",
13+
friendlyId: "run_friendly_1",
14+
status: "QUEUED",
15+
cancelledAt: undefined,
16+
cancelReason: undefined,
17+
delayUntil: undefined,
18+
taskIdentifier: "hello-world",
19+
createdAt: NOW,
20+
payload: { message: "hi" },
21+
payloadType: "application/json",
22+
metadata: undefined,
23+
metadataType: undefined,
24+
seedMetadata: undefined,
25+
seedMetadataType: undefined,
26+
idempotencyKey: undefined,
27+
idempotencyKeyOptions: undefined,
28+
isTest: false,
29+
depth: 0,
30+
ttl: "10m",
31+
tags: [],
32+
runTags: [],
33+
lockedToVersion: undefined,
34+
resumeParentOnCompletion: false,
35+
parentTaskRunId: undefined,
36+
traceId: "trace_1",
37+
spanId: "span_1",
38+
parentSpanId: undefined,
39+
runtimeEnvironmentId: "env_a",
40+
engine: "V2",
41+
workerQueue: "worker-queue-1",
42+
queue: "task/hello-world",
43+
concurrencyKey: undefined,
44+
machinePreset: "small-1x",
45+
realtimeStreamsVersion: "v1",
46+
maxAttempts: 3,
47+
maxDurationInSeconds: 3600,
48+
replayedFromTaskRunFriendlyId: undefined,
49+
annotations: undefined,
50+
traceContext: undefined,
51+
scheduleId: undefined,
52+
batchId: undefined,
53+
parentTaskRunFriendlyId: undefined,
54+
rootTaskRunFriendlyId: undefined,
55+
...overrides,
56+
};
57+
}
58+
59+
const ENV_ROW = {
60+
slug: "dev",
61+
project: { slug: "hello-world", organization: { slug: "references" } },
62+
};
63+
64+
describe("buildSyntheticReplayTaskRun", () => {
65+
it("returns the adapted TaskRun shape when traceId and spanId are present", () => {
66+
const taskRun = buildSyntheticReplayTaskRun({
67+
synthetic: makeSyntheticRun(),
68+
envRow: ENV_ROW,
69+
});
70+
expect(taskRun).not.toBeNull();
71+
expect(taskRun!.traceId).toBe("trace_1");
72+
expect(taskRun!.spanId).toBe("span_1");
73+
expect(taskRun!.project.slug).toBe("hello-world");
74+
expect(taskRun!.project.organization.slug).toBe("references");
75+
expect(taskRun!.runtimeEnvironment.slug).toBe("dev");
76+
});
77+
78+
it("returns null when the snapshot has no traceId", () => {
79+
// ReplayTaskRunService builds `00-${traceId}-${spanId}-01` without
80+
// guarding for undefined. Falling through with a missing traceId
81+
// would emit `00-undefined-...-01`, an invalid W3C traceparent that
82+
// OTel silently drops, breaking the replayed run's trace linkage to
83+
// the original. The helper must refuse rather than degrade silently.
84+
const taskRun = buildSyntheticReplayTaskRun({
85+
synthetic: makeSyntheticRun({ traceId: undefined }),
86+
envRow: ENV_ROW,
87+
});
88+
expect(taskRun).toBeNull();
89+
});
90+
91+
it("returns null when the snapshot has no spanId", () => {
92+
const taskRun = buildSyntheticReplayTaskRun({
93+
synthetic: makeSyntheticRun({ spanId: undefined }),
94+
envRow: ENV_ROW,
95+
});
96+
expect(taskRun).toBeNull();
97+
});
98+
99+
it("returns null when both traceId and spanId are missing", () => {
100+
const taskRun = buildSyntheticReplayTaskRun({
101+
synthetic: makeSyntheticRun({ traceId: undefined, spanId: undefined }),
102+
envRow: ENV_ROW,
103+
});
104+
expect(taskRun).toBeNull();
105+
});
106+
});

0 commit comments

Comments
 (0)