Skip to content

Commit 6b8a54e

Browse files
d-csclaude
andcommitted
feat(webapp): mollifier read-fallback for /api/v1/runs/{id}/trace
Phase A1 of the mollifier API parity work. The trace endpoint now falls back to the mollifier buffer when the run isn't in Postgres yet, returning an empty trace skeleton (200) instead of a 404 for buffered runs. `findResource` is restructured into a discriminated union — `pg` for real TaskRun rows, `buffer` for synthesised shapes from the buffer entry. The authorization branch handles both shapes; the handler renders an empty `{ trace: { traceId, rootSpan: null, events: [] } }` for buffered runs so the customer sees the same 200 contract they'd get for a freshly-triggered PG run that hasn't had its first span recorded yet. See _plans/2026-05-19-mollifier-api-parity.md for the full plan and _plans/2026-05-19-mollifier-listing-design.md for the read-fallback companion infrastructure this builds on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8d036a commit 6b8a54e

1 file changed

Lines changed: 64 additions & 13 deletions

File tree

apps/webapp/app/routes/api.v1.runs.$runId.trace.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,92 @@ import {
88
} from "~/services/routeBuilders/apiBuilder.server";
99
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
1010
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
11+
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
1112

1213
const ParamsSchema = z.object({
1314
runId: z.string(), // This is the run friendly ID
1415
});
1516

17+
// Discriminator on the resolved resource — `pg` is the real Prisma TaskRun
18+
// row, `buffer` is a synthesised shape from the mollifier buffer for runs
19+
// whose drainer hasn't yet materialised them. The handler renders an empty
20+
// trace for buffered runs so the customer sees the same 200 shape they'd
21+
// get for a freshly-triggered PG run with no spans yet (matches the
22+
// pass-through control case in scripts/mollifier-api-parity.sh).
23+
type ResolvedRun =
24+
| { source: "pg"; run: Awaited<ReturnType<typeof findPgRun>> & {} }
25+
| { source: "buffer"; run: NonNullable<Awaited<ReturnType<typeof findRunByIdWithMollifierFallback>>> };
26+
27+
async function findPgRun(runId: string, environmentId: string) {
28+
return $replica.taskRun.findFirst({
29+
where: { friendlyId: runId, runtimeEnvironmentId: environmentId },
30+
});
31+
}
32+
1633
export const loader = createLoaderApiRoute(
1734
{
1835
params: ParamsSchema,
1936
allowJWT: true,
2037
corsStrategy: "all",
21-
findResource: (params, auth) => {
22-
return $replica.taskRun.findFirst({
23-
where: {
24-
friendlyId: params.runId,
25-
runtimeEnvironmentId: auth.environment.id,
26-
},
38+
findResource: async (params, auth): Promise<ResolvedRun | null> => {
39+
const pgRun = await findPgRun(params.runId, auth.environment.id);
40+
if (pgRun) return { source: "pg", run: pgRun };
41+
42+
const buffered = await findRunByIdWithMollifierFallback({
43+
runId: params.runId,
44+
environmentId: auth.environment.id,
45+
organizationId: auth.environment.organizationId,
2746
});
47+
if (buffered) return { source: "buffer", run: buffered };
48+
49+
return null;
2850
},
2951
shouldRetryNotFound: true,
3052
authorization: {
3153
action: "read",
32-
resource: (run) => {
54+
resource: (resolved) => {
55+
if (resolved.source === "pg") {
56+
const run = resolved.run;
57+
const resources = [
58+
{ type: "runs", id: run.friendlyId },
59+
{ type: "tasks", id: run.taskIdentifier },
60+
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
61+
];
62+
if (run.batchId) {
63+
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
64+
}
65+
return anyResource(resources);
66+
}
67+
const run = resolved.run;
3368
const resources = [
3469
{ type: "runs", id: run.friendlyId },
35-
{ type: "tasks", id: run.taskIdentifier },
36-
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
70+
...(run.taskIdentifier ? [{ type: "tasks", id: run.taskIdentifier }] : []),
71+
...run.tags.map((tag) => ({ type: "tags", id: tag })),
3772
];
38-
if (run.batchId) {
39-
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
40-
}
4173
return anyResource(resources);
4274
},
4375
},
4476
},
45-
async ({ resource: run, authentication }) => {
77+
async ({ resource: resolved, authentication }) => {
78+
if (resolved.source === "buffer") {
79+
// Buffered runs have no events ingested yet — the drainer hasn't
80+
// materialised the PG row and the worker hasn't started executing.
81+
// Return an empty trace skeleton so the customer's SDK sees the same
82+
// 200 shape it would get from a freshly-triggered PG run that hasn't
83+
// had its first span recorded yet.
84+
return json(
85+
{
86+
trace: {
87+
traceId: resolved.run.traceId ?? "",
88+
rootSpan: null,
89+
events: [],
90+
},
91+
},
92+
{ status: 200 }
93+
);
94+
}
95+
96+
const run = resolved.run;
4697
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
4798

4899
const traceSummary = await eventRepository.getTraceDetailedSummary(

0 commit comments

Comments
 (0)