Skip to content

Commit 0ac3244

Browse files
d-csclaude
andcommitted
feat(webapp): dashboard parity for mollifier-buffered runs
Dashboard run detail, span detail, streams view, realtime subscription, redirect routes, replay/cancel/idempotency-reset action routes, the logs download route, and the cancel dialog all handle buffered runs by falling back to the mollifier snapshot. Stacked on the mutations PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0547ba9 commit 0ac3244

21 files changed

Lines changed: 1024 additions & 43 deletions

apps/webapp/app/components/runs/v3/CancelRunDialog.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NoSymbolIcon } from "@heroicons/react/24/solid";
22
import { DialogClose } from "@radix-ui/react-dialog";
33
import { Form, useNavigation } from "@remix-run/react";
4+
import { useEffect, useRef } from "react";
45
import { Button } from "~/components/primitives/Buttons";
56
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
67
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -10,14 +11,35 @@ import { SpinnerWhite } from "~/components/primitives/Spinner";
1011
type CancelRunDialogProps = {
1112
runFriendlyId: string;
1213
redirectPath: string;
14+
// Optional: when provided, close the dialog as soon as the cancel
15+
// action transitions to "loading" (the redirect is in flight). Lets
16+
// the caller control the open state without interfering with the
17+
// form's submit name=value pair the way `<DialogClose asChild>`
18+
// around the submit button does.
19+
onCancelSubmitted?: () => void;
1320
};
1421

15-
export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
22+
export function CancelRunDialog({
23+
runFriendlyId,
24+
redirectPath,
25+
onCancelSubmitted,
26+
}: CancelRunDialogProps) {
1627
const navigation = useNavigation();
1728

1829
const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
1930
const isLoading = navigation.formAction === formAction;
2031

32+
const wasSubmitting = useRef(false);
33+
useEffect(() => {
34+
if (!onCancelSubmitted) return;
35+
if (navigation.state === "submitting" && navigation.formAction === formAction) {
36+
wasSubmitting.current = true;
37+
} else if (wasSubmitting.current && navigation.state !== "submitting") {
38+
wasSubmitting.current = false;
39+
onCancelSubmitted();
40+
}
41+
}, [navigation.state, navigation.formAction, formAction, onCancelSubmitted]);
42+
2143
return (
2244
<DialogContent key="cancel">
2345
<DialogHeader>Cancel this run?</DialogHeader>

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { logger } from "~/services/logger.server";
33
import { singleton } from "~/utils/singleton";
44
import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse";
55
import { throttle } from "~/utils/throttle";
6+
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
7+
import { deserialiseSnapshot } from "@trigger.dev/redis-worker";
68
import { tracePubSub } from "~/v3/services/tracePubSub.server";
79

810
const PING_INTERVAL = 5_000;
@@ -37,17 +39,45 @@ export class RunStreamPresenter {
3739
},
3840
});
3941

40-
if (!run) {
42+
// Fall back to the mollifier buffer when the run isn't in PG yet.
43+
// The buffered run has no execution events to stream, but we still
44+
// attach a trace-pubsub subscription using the snapshot's traceId
45+
// so that the moment the drainer materialises the row and execution
46+
// begins, those events flow to this open SSE connection. Closing
47+
// with 404 would force the dashboard to keep retrying.
48+
let traceId: string | null = run?.traceId ?? null;
49+
if (!traceId) {
50+
const buffer = getMollifierBuffer();
51+
if (buffer) {
52+
try {
53+
const entry = await buffer.getEntry(runFriendlyId);
54+
if (entry) {
55+
const snapshot = deserialiseSnapshot<{ traceId?: string }>(entry.payload);
56+
if (typeof snapshot.traceId === "string") {
57+
traceId = snapshot.traceId;
58+
}
59+
}
60+
} catch (err) {
61+
logger.warn("RunStreamPresenter buffer fallback failed", {
62+
runFriendlyId,
63+
err: err instanceof Error ? err.message : String(err),
64+
});
65+
}
66+
}
67+
}
68+
69+
if (!traceId) {
4170
throw new Response("Not found", { status: 404 });
4271
}
72+
const resolvedRun = { traceId };
4373

4474
logger.info("RunStreamPresenter.start", {
4575
runFriendlyId,
46-
traceId: run.traceId,
76+
traceId: resolvedRun.traceId,
4777
});
4878

4979
// Subscribe to trace updates
50-
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
80+
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(resolvedRun.traceId);
5181

5282
// Only send max every 1 second
5383
const throttledSend = throttle(
@@ -105,7 +135,7 @@ export class RunStreamPresenter {
105135
cleanup: () => {
106136
logger.info("RunStreamPresenter.cleanup", {
107137
runFriendlyId,
108-
traceId: run.traceId,
138+
traceId: resolvedRun.traceId,
109139
});
110140

111141
// Remove message listener
@@ -119,13 +149,13 @@ export class RunStreamPresenter {
119149
.then(() => {
120150
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
121151
runFriendlyId,
122-
traceId: run.traceId,
152+
traceId: resolvedRun.traceId,
123153
});
124154
})
125155
.catch((error) => {
126156
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
127157
runFriendlyId,
128-
traceId: run.traceId,
158+
traceId: resolvedRun.traceId,
129159
error: {
130160
name: error.name,
131161
message: error.message,

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { prisma } from "~/db.server";
44
import { redirectWithErrorMessage } from "~/models/message.server";
55
import { requireUser } from "~/services/session.server";
66
import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder";
7+
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
78

89
const ParamsSchema = z.object({
910
runParam: z.string(),
@@ -51,6 +52,26 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
5152
});
5253

5354
if (!run) {
55+
// Admin impersonation route — bypass org membership so admins can
56+
// open any buffered run by friendlyId, mirroring the existing PG
57+
// behaviour above (no membership filter on the find).
58+
const buffered = await findBufferedRunRedirectInfo({
59+
runFriendlyId: runParam,
60+
userId: user.id,
61+
skipOrgMembershipCheck: true,
62+
});
63+
if (buffered) {
64+
return redirect(
65+
impersonate(
66+
v3RunPath(
67+
{ slug: buffered.organizationSlug },
68+
{ slug: buffered.projectSlug },
69+
{ slug: buffered.environmentSlug },
70+
{ friendlyId: runParam }
71+
)
72+
)
73+
);
74+
}
5475
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
5576
ephemeral: false,
5677
});

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

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
8888
import { useSearchParams } from "~/hooks/useSearchParam";
8989
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
9090
import { useHasAdminAccess } from "~/hooks/useUser";
91+
import { env } from "~/env.server";
9192
import { findProjectBySlug } from "~/models/project.server";
9293
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
9394
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
9495
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
96+
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
97+
import { buildSyntheticTraceForBufferedRun } from "~/v3/mollifier/syntheticTrace.server";
9598
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
9699
import { getImpersonationId } from "~/services/impersonation.server";
97100
import { logger } from "~/services/logger.server";
@@ -277,6 +280,31 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
277280
);
278281
}
279282

283+
// PG miss → try the mollifier buffer. When the gate diverts a trigger
284+
// the run sits in Redis until the drainer materialises it; without
285+
// this fallback the run-detail page 404s for the brief buffered window
286+
// even though the API has accepted the trigger and returned an id.
287+
const buffered = await tryMollifiedRunFallback({
288+
runFriendlyId: runParam,
289+
organizationSlug,
290+
projectSlug: projectParam,
291+
envSlug: envParam,
292+
userId,
293+
});
294+
295+
if (buffered) {
296+
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
297+
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
298+
299+
return json({
300+
run: buffered.run,
301+
trace: buffered.trace,
302+
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
303+
resizable: { parent, tree },
304+
runsList: null,
305+
});
306+
}
307+
280308
throw error;
281309
}
282310

@@ -305,6 +333,52 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
305333
});
306334
};
307335

336+
async function tryMollifiedRunFallback(args: {
337+
runFriendlyId: string;
338+
organizationSlug: string;
339+
projectSlug: string;
340+
envSlug: string;
341+
userId: string;
342+
}) {
343+
const project = await findProjectBySlug(args.organizationSlug, args.projectSlug, args.userId);
344+
if (!project) return null;
345+
const environment = await findEnvironmentBySlug(project.id, args.envSlug, args.userId);
346+
if (!environment) return null;
347+
348+
const buffered = await findRunByIdWithMollifierFallback({
349+
runId: args.runFriendlyId,
350+
environmentId: environment.id,
351+
organizationId: project.organizationId,
352+
});
353+
if (!buffered) return null;
354+
355+
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,
369+
environment: {
370+
id: environment.id,
371+
organizationId: project.organizationId,
372+
type: environment.type,
373+
slug: environment.slug,
374+
userId: undefined,
375+
userName: undefined,
376+
},
377+
},
378+
trace: buildSyntheticTraceForBufferedRun(buffered),
379+
};
380+
}
381+
308382
type LoaderData = SerializeFrom<typeof loader>;
309383

310384
export default function Page() {
@@ -407,23 +481,17 @@ export default function Page() {
407481
/>
408482
</Dialog>
409483
{run.isFinished ? null : (
410-
<Dialog key={`cancel-${run.friendlyId}`}>
411-
<DialogTrigger asChild>
412-
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
413-
Cancel run…
414-
</Button>
415-
</DialogTrigger>
416-
<CancelRunDialog
417-
runFriendlyId={run.friendlyId}
418-
redirectPath={v3RunSpanPath(
419-
organization,
420-
project,
421-
environment,
422-
{ friendlyId: run.friendlyId },
423-
{ spanId: run.spanId }
424-
)}
425-
/>
426-
</Dialog>
484+
<ControlledCancelRunDialog
485+
key={`cancel-${run.friendlyId}`}
486+
runFriendlyId={run.friendlyId}
487+
redirectPath={v3RunSpanPath(
488+
organization,
489+
project,
490+
environment,
491+
{ friendlyId: run.friendlyId },
492+
{ spanId: run.spanId }
493+
)}
494+
/>
427495
)}
428496
</PageAccessories>
429497
</NavBar>
@@ -587,6 +655,35 @@ function TraceView({
587655
);
588656
}
589657

658+
// Controlled wrapper around the cancel dialog. Owns the Radix open state
659+
// so the dialog closes itself once the cancel action transitions through
660+
// submission. We can't `<DialogClose asChild>`-wrap the submit button
661+
// because Radix's onClick handler swallows the button's name=value pair
662+
// that the form action depends on for `redirectUrl`.
663+
function ControlledCancelRunDialog({
664+
runFriendlyId,
665+
redirectPath,
666+
}: {
667+
runFriendlyId: string;
668+
redirectPath: string;
669+
}) {
670+
const [open, setOpen] = useState(false);
671+
return (
672+
<Dialog open={open} onOpenChange={setOpen}>
673+
<DialogTrigger asChild>
674+
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
675+
Cancel run…
676+
</Button>
677+
</DialogTrigger>
678+
<CancelRunDialog
679+
runFriendlyId={runFriendlyId}
680+
redirectPath={redirectPath}
681+
onCancelSubmitted={() => setOpen(false)}
682+
/>
683+
</Dialog>
684+
);
685+
}
686+
590687
function NoLogsView({ run, resizable }: Pick<LoaderData, "run" | "resizable">) {
591688
const plan = useCurrentPlan();
592689
const organization = useOrganization();
@@ -616,9 +713,13 @@ function NoLogsView({ run, resizable }: Pick<LoaderData, "run" | "resizable">) {
616713
>
617714
<div className="grid h-full place-items-center">
618715
{daysSinceCompleted === undefined ? (
619-
<InfoPanel variant="info" icon={InformationCircleIcon} title="We delete old logs">
716+
<InfoPanel
717+
variant="info"
718+
icon={InformationCircleIcon}
719+
title="Waiting to start"
720+
>
620721
<Paragraph variant="small">
621-
We tidy up older logs to keep things running smoothly.
722+
This run is queued. Logs will appear here once it begins executing.
622723
</Paragraph>
623724
</InfoPanel>
624725
) : isWithinLogRetention ? (

apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
44
import { requireUserId } from "~/services/session.server";
5-
import { v3RunSpanPath } from "~/utils/pathBuilder";
5+
import { v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
6+
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
67

78
const ParamsSchema = z.object({
89
projectRef: z.string(),
@@ -44,6 +45,28 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
4445
});
4546

4647
if (!run) {
48+
// Fall back to the mollifier buffer so a /projects/v3/{ref}/runs/{id}
49+
// share link works during the buffered window.
50+
const buffered = await findBufferedRunRedirectInfo({
51+
runFriendlyId: validatedParams.runParam,
52+
userId,
53+
});
54+
if (buffered) {
55+
const url = new URL(request.url);
56+
const searchParams = url.searchParams;
57+
if (!searchParams.has("span") && buffered.spanId) {
58+
searchParams.set("span", buffered.spanId);
59+
}
60+
return redirect(
61+
v3RunPath(
62+
{ slug: buffered.organizationSlug },
63+
{ slug: buffered.projectSlug },
64+
{ slug: buffered.environmentSlug },
65+
{ friendlyId: validatedParams.runParam },
66+
searchParams
67+
)
68+
);
69+
}
4770
throw new Response("Not found", { status: 404 });
4871
}
4972

0 commit comments

Comments
 (0)