Skip to content

Commit e21b68c

Browse files
d-csclaude
andauthored
feat(webapp): dashboard parity for mollifier-buffered runs (#3757)
## Summary Dashboard surfaces handle buffered runs by falling back to the mollifier snapshot: - Run detail, span detail, streams view (`_app.../runs.\$runParam`, `resources.../spans.\$spanParam`, `resources.../streams.\$streamKey`). - Redirect routes (`@.runs.\$runParam`, `runs.\$runParam`, `projects.v3.\$projectRef.runs.\$runParam`). - Action routes — cancel / replay / idempotency-reset / debug — under `resources.taskruns/...` and `resources.../idempotencyKey.reset`. - Logs download. - Realtime subscription route + per-run resource (`realtime.v1.runs.\$runId`, `resources.../realtime.v1.*`). - `CancelRunDialog` gains an `onCancelSubmitted` callback so submit isn't raced by the Radix `DialogClose` wrapper. Stacked on the mutations PR. ## Test plan - [x] \`pnpm run typecheck --filter webapp\` passes - [x] \`pnpm run test --filter webapp test/mollifierRealtimeRunResource.test.ts\` passes - [x] \`pnpm run test --filter webapp test/mollifierRealtimeRunResourceBuffer.test.ts\` passes - [x] \`pnpm run test --filter webapp test/mollifierRealtimeSubscription.test.ts\` passes - [x] Manual smoke: trigger a buffered run, open it in the dashboard, replay/cancel from the UI --- ## Ship-gate follow-up fixes - **Auto-redirect to root span on direct nav** — loader sets `?span=` from root span (PG) or buffered snapshot spanId before 302'ing, so bookmark/share-link/direct-nav doesn't leave the panel collapsed. - **RunPresenter switches from `findFirstOrThrow` to `findFirst` + typed `RunNotInPgError`** — kills the per-poll `PrismaClient error` log spam for buffered runs without changing the route-loader's fallback flow. - **Span detail panel renders for buffered runs** — `SpanPresenter.call` now falls back to `findRunByIdWithMollifierFallback` + `buildSyntheticSpanRun` instead of returning undefined and triggering the "Event not found" toast loop. - **Logs download for buffered runs returns a gzipped placeholder line** — replaces the 404 with a content-encoded line explaining the run is queued. Same org-membership gate as the PG path. - **Admin Debug-Run button hidden for buffered runs + SpanRun circular type alias broken** (squashed) — buttons gate on a new `isBuffered` flag on the synthetic SpanRun. Required grounding SpanRun in `SpanPresenter.getRun` to break a circular type alias TS no longer tolerates once `isBuffered` is a literal field on the shape. - **Replay action requires user auth + org-membership** (🚩 Devin finding) — `action` was unauthenticated and the PG `findFirst` had no org filter, so any caller with a valid `runParam` could replay any run. Buffered fallback inherited the same gap. Fixed to mirror the cancel route. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e195077 commit e21b68c

19 files changed

Lines changed: 1219 additions & 39 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Mollifier dashboard surface: run-detail page renders buffered runs via synthetic trace, header, and span shapes; admin-only "Buffered" indicator and drainer LOG event in the trace tree.

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,18 @@ import { SpinnerWhite } from "~/components/primitives/Spinner";
1010
type CancelRunDialogProps = {
1111
runFriendlyId: string;
1212
redirectPath: string;
13+
// Fired on submit so the parent can close the Radix Dialog without
14+
// wrapping the submit button in `DialogClose` — that wrapper races
15+
// submit (close fires first, unmounts the form, and the cancel POST
16+
// never lands). Optional so existing call sites still type-check.
17+
onCancelSubmitted?: () => void;
1318
};
1419

15-
export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
20+
export function CancelRunDialog({
21+
runFriendlyId,
22+
redirectPath,
23+
onCancelSubmitted,
24+
}: CancelRunDialogProps) {
1625
const navigation = useNavigation();
1726

1827
const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
@@ -27,7 +36,11 @@ export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialog
2736
</Paragraph>
2837
<FormButtons
2938
confirmButton={
30-
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
39+
<Form
40+
action={`/resources/taskruns/${runFriendlyId}/cancel`}
41+
method="post"
42+
onSubmit={() => onCancelSubmitted?.()}
43+
>
3144
<Button
3245
type="submit"
3346
name="redirectUrl"

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ export class RunEnvironmentMismatchError extends Error {
2020
}
2121
}
2222

23+
// Thrown by `call()` when the run isn't in PG. The route loader catches
24+
// this and falls back to the mollifier buffer via `tryMollifiedRunFallback`.
25+
// Using a typed error (rather than Prisma's `findFirstOrThrow` exception)
26+
// keeps the buffered case off the PrismaClient error path — that path
27+
// emits a `PrismaClient error` log every time it fires, which on the
28+
// run-detail page polls becomes per-tick log spam and Sentry noise for
29+
// any run that legitimately lives in the buffer.
30+
export class RunNotInPgError extends Error {
31+
constructor(public readonly runFriendlyId: string) {
32+
super(`Run ${runFriendlyId} not in PG`);
33+
this.name = "RunNotInPgError";
34+
}
35+
}
36+
2337
export class RunPresenter {
2438
#prismaClient: PrismaClient;
2539

@@ -42,7 +56,13 @@ export class RunPresenter {
4256
showDeletedLogs: boolean;
4357
showDebug: boolean;
4458
}) {
45-
const run = await this.#prismaClient.taskRun.findFirstOrThrow({
59+
// `findFirst` + explicit null check (not `findFirstOrThrow`) because
60+
// a missing PG row is the *expected* path for buffered runs — the
61+
// route catches `RunNotInPgError` and falls back to the synthesised
62+
// buffer view. `findFirstOrThrow` would log a `PrismaClient error`
63+
// every tick of the page poll, masking real DB issues with synthetic
64+
// not-found noise.
65+
const run = await this.#prismaClient.taskRun.findFirst({
4666
select: {
4767
id: true,
4868
createdAt: true,
@@ -106,6 +126,10 @@ export class RunPresenter {
106126
},
107127
});
108128

129+
if (!run) {
130+
throw new RunNotInPgError(runFriendlyId);
131+
}
132+
109133
if (environmentSlug !== run.runtimeEnvironment.slug) {
110134
throw new RunEnvironmentMismatchError(
111135
`Run ${runFriendlyId} is not in environment ${environmentSlug}`

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

Lines changed: 39 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 { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
68
import { tracePubSub } from "~/v3/services/tracePubSub.server";
79

810
const PING_INTERVAL = 5_000;
@@ -37,17 +39,48 @@ 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+
// 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);
59+
if (typeof snapshot.traceId === "string") {
60+
traceId = snapshot.traceId;
61+
}
62+
}
63+
} catch (err) {
64+
logger.warn("RunStreamPresenter buffer fallback failed", {
65+
runFriendlyId,
66+
err: err instanceof Error ? err.message : String(err),
67+
});
68+
}
69+
}
70+
}
71+
72+
if (!traceId) {
4173
throw new Response("Not found", { status: 404 });
4274
}
75+
const resolvedRun = { traceId };
4376

4477
logger.info("RunStreamPresenter.start", {
4578
runFriendlyId,
46-
traceId: run.traceId,
79+
traceId: resolvedRun.traceId,
4780
});
4881

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

5285
// Only send max every 1 second
5386
const throttledSend = throttle(
@@ -105,7 +138,7 @@ export class RunStreamPresenter {
105138
cleanup: () => {
106139
logger.info("RunStreamPresenter.cleanup", {
107140
runFriendlyId,
108-
traceId: run.traceId,
141+
traceId: resolvedRun.traceId,
109142
});
110143

111144
// Remove message listener
@@ -119,13 +152,13 @@ export class RunStreamPresenter {
119152
.then(() => {
120153
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
121154
runFriendlyId,
122-
traceId: run.traceId,
155+
traceId: resolvedRun.traceId,
123156
});
124157
})
125158
.catch((error) => {
126159
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
127160
runFriendlyId,
128-
traceId: run.traceId,
161+
traceId: resolvedRun.traceId,
129162
error: {
130163
name: error.name,
131164
message: error.message,

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import {
3232
extractAIEmbedData,
3333
} from "~/components/runs/v3/ai";
3434
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
35+
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
36+
import { buildSyntheticSpanRun } from "~/v3/mollifier/syntheticSpanRun.server";
3537

3638
export type PromptSpanData = {
3739
slug: string;
@@ -72,9 +74,21 @@ function extractPromptSpanData(properties: Record<string, unknown>): PromptSpanD
7274
};
7375
}
7476

77+
// SpanRun is grounded in the PG-path `getRun` method rather than
78+
// inferred from `call`'s return type. The buffered branch of `call`
79+
// routes through `buildSyntheticSpanRun`, and that helper is annotated
80+
// `Promise<SpanRun>` — if SpanRun were derived from `call` it would
81+
// close a loop TS no longer tolerates ("Type alias 'Result' circularly
82+
// references itself"). `getRun` is the canonical source for the shape
83+
// (the synthetic helper just rebuilds the same shape from a buffer
84+
// snapshot), and it doesn't recurse, so grounding here breaks the
85+
// cycle while keeping Span available off `call` (Span's path through
86+
// `#getSpan` has no synthetic indirection).
87+
export type SpanRun = NonNullable<
88+
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["getRun"]>>
89+
>;
7590
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
7691
export type Span = NonNullable<NonNullable<Result>["span"]>;
77-
export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
7892
type FindRunResult = NonNullable<
7993
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
8094
>;
@@ -84,12 +98,18 @@ export class SpanPresenter extends BasePresenter {
8498
public async call({
8599
userId,
86100
projectSlug,
101+
envSlug,
87102
spanId,
88103
runFriendlyId,
89104
linkedRunId,
90105
}: {
91106
userId: string;
92107
projectSlug: string;
108+
// Optional for backwards compatibility, required for the mollifier
109+
// buffer fallback when the parent run isn't yet in PG — we need to
110+
// resolve the env id to satisfy `findRunByIdWithMollifierFallback`'s
111+
// auth check.
112+
envSlug?: string;
93113
spanId: string;
94114
runFriendlyId: string;
95115
linkedRunId?: string;
@@ -127,7 +147,32 @@ export class SpanPresenter extends BasePresenter {
127147
});
128148

129149
if (!parentRun) {
130-
return;
150+
// PG miss → fall back to the mollifier buffer. Without this the
151+
// right-side span detail panel on the run-detail page never
152+
// resolves for buffered runs: `call()` returns undefined, the
153+
// resource route redirects with an "Event not found" toast, the
154+
// run-detail page reloads, the toast fires again — a perpetual
155+
// spin until the drainer materialises the row. Synthesise a
156+
// SpanRun straight from the buffer snapshot, reusing
157+
// `buildSyntheticSpanRun` (the same helper the run-detail
158+
// loader's header fallback already uses).
159+
if (!envSlug) return;
160+
const envRow = await this._replica.runtimeEnvironment.findFirst({
161+
where: { project: { id: project.id }, slug: envSlug },
162+
select: { id: true, slug: true, type: true, organizationId: true },
163+
});
164+
if (!envRow) return;
165+
const buffered = await findRunByIdWithMollifierFallback({
166+
runId: runFriendlyId,
167+
environmentId: envRow.id,
168+
organizationId: envRow.organizationId,
169+
});
170+
if (!buffered) return;
171+
const synth = await buildSyntheticSpanRun({
172+
run: buffered,
173+
environment: { id: envRow.id, slug: envRow.slug, type: envRow.type },
174+
});
175+
return { type: "run" as const, run: synth };
131176
}
132177

133178
const { traceId } = parentRun;
@@ -373,6 +418,7 @@ export class SpanPresenter extends BasePresenter {
373418
traceId: run.traceId,
374419
spanId: run.spanId,
375420
isCached: !!linkedRunId,
421+
isBuffered: false,
376422
machinePreset: machine?.name,
377423
taskEventStore: run.taskEventStore,
378424
externalTraceId,

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ 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";
7+
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
78

89
const ParamsSchema = z.object({
910
runParam: z.string(),
@@ -32,6 +33,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
3233
friendlyId: runParam,
3334
},
3435
select: {
36+
spanId: true,
3537
runtimeEnvironment: {
3638
select: {
3739
slug: true,
@@ -51,16 +53,45 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
5153
});
5254

5355
if (!run) {
56+
// Admin impersonation route — bypass org membership so admins can
57+
// open any buffered run by friendlyId, mirroring the existing PG
58+
// behaviour above (no membership filter on the find).
59+
const buffered = await findBufferedRunRedirectInfo({
60+
runFriendlyId: runParam,
61+
userId: user.id,
62+
skipOrgMembershipCheck: true,
63+
});
64+
if (buffered) {
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(
70+
{ slug: buffered.organizationSlug },
71+
{ slug: buffered.projectSlug },
72+
{ slug: buffered.environmentSlug },
73+
{ friendlyId: runParam },
74+
{ spanId: buffered.spanId }
75+
)
76+
: v3RunPath(
77+
{ slug: buffered.organizationSlug },
78+
{ slug: buffered.projectSlug },
79+
{ slug: buffered.environmentSlug },
80+
{ friendlyId: runParam }
81+
);
82+
return redirect(impersonate(path));
83+
}
5484
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
5585
ephemeral: false,
5686
});
5787
}
5888

59-
const path = v3RunPath(
89+
const path = v3RunSpanPath(
6090
{ slug: run.project.organization.slug },
6191
{ slug: run.project.slug },
6292
{ slug: run.runtimeEnvironment.slug },
63-
{ friendlyId: runParam }
93+
{ friendlyId: runParam },
94+
{ spanId: run.spanId }
6495
);
6596

6697
return redirect(impersonate(path));

0 commit comments

Comments
 (0)