Skip to content

Commit 6a81f13

Browse files
d-csclaude
andcommitted
refactor(webapp): extract synthetic buffered-run response builders + drop scaffolding GET on attempts
Two related cleanups on the mollifier read surface: 1. Extract the buffered-run response bodies for the spans-detail and trace endpoints into pure helpers (apps/webapp/app/v3/mollifier/syntheticApiResponses.server.ts: buildSyntheticSpanDetailBody, buildSyntheticTraceBody). The route bodies were carrying the only copy of the terminal-state derivation (CANCELED / FAILED → isError / isPartial / isCancelled) with no unit coverage; extracting them lets us pin the contract directly. The route files now just authenticate, resolve, validate the spanId, and forward — no body shape logic in routes. 2. Drop the GET loader on api.v1.runs.\$runParam.attempts.ts. It was added in this PR solely to fix a pre-existing Remix "no loader" 400 on a URL no SDK consumer was actually calling, and to give the mollifier-parity script a stable assertion target. The detailed attempt list lives on the v3 retrieve endpoint — the GET was scaffolding rather than product surface, and Devin's review flagged it as such. Reverted to action-only. Tests: 16 cases in apps/webapp/test/mollifierSyntheticApiResponses.test.ts covering QUEUED / CANCELED / FAILED for each body, plus identity and default-field passthrough. Pins the FAILED-terminal-state regression that shipped briefly with isPartial:true. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 673be9e commit 6a81f13

5 files changed

Lines changed: 244 additions & 142 deletions

File tree

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

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
1111
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
1212
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
13+
import { buildSyntheticSpanDetailBody } from "~/v3/mollifier/syntheticApiResponses.server";
1314

1415
const ParamsSchema = z.object({
1516
runId: z.string(),
@@ -89,26 +90,7 @@ export const loader = createLoaderApiRoute(
8990
if (resolved.run.spanId !== params.spanId) {
9091
return json({ error: "Span not found" }, { status: 404 });
9192
}
92-
// CANCELED and FAILED are terminal states. A FAILED buffered run is
93-
// errored (drainer exhausted retries or gate rejected it) and must
94-
// not signal "still in progress" — mirrors syntheticTrace.server.ts.
95-
const isCancelled = resolved.run.status === "CANCELED";
96-
const isFailed = resolved.run.status === "FAILED";
97-
return json(
98-
{
99-
spanId: resolved.run.spanId,
100-
parentId: resolved.run.parentSpanId ?? null,
101-
runId: resolved.run.friendlyId,
102-
message: resolved.run.taskIdentifier ?? "",
103-
isError: isFailed,
104-
isPartial: !isCancelled && !isFailed,
105-
isCancelled,
106-
level: "TRACE",
107-
startTime: resolved.run.createdAt,
108-
durationMs: 0,
109-
},
110-
{ status: 200 }
111-
);
93+
return json(buildSyntheticSpanDetailBody(resolved.run), { status: 200 });
11294
}
11395

11496
const run = resolved.run;

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

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
1010
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
1111
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
12+
import { buildSyntheticTraceBody } from "~/v3/mollifier/syntheticApiResponses.server";
1213

1314
const ParamsSchema = z.object({
1415
runId: z.string(), // This is the run friendly ID
@@ -81,40 +82,10 @@ export const loader = createLoaderApiRoute(
8182
if (resolved.source === "buffer") {
8283
// Buffered runs have no events ingested yet — the drainer hasn't
8384
// materialised the PG row and the worker hasn't started executing.
84-
// Synthesise a single partial span that satisfies the SDK's
85-
// RetrieveRunTraceResponseBody schema (rootSpan is non-nullable).
86-
const buffered = resolved.run;
87-
return json(
88-
{
89-
trace: {
90-
traceId: buffered.traceId ?? "",
91-
rootSpan: {
92-
id: buffered.spanId ?? "",
93-
runId: buffered.friendlyId,
94-
data: {
95-
message: buffered.taskIdentifier ?? "",
96-
taskSlug: buffered.taskIdentifier ?? undefined,
97-
events: [],
98-
startTime: buffered.createdAt,
99-
duration: 0,
100-
isError: buffered.status === "FAILED",
101-
// CANCELED and FAILED are terminal states — the span
102-
// shouldn't signal "still in progress" once the run has
103-
// reached either. Mirrors the sibling
104-
// api.v1.runs.$runId.spans.$spanId.ts and
105-
// syntheticTrace.server.ts logic.
106-
isPartial: buffered.status !== "CANCELED" && buffered.status !== "FAILED",
107-
isCancelled: buffered.status === "CANCELED",
108-
level: "TRACE",
109-
queueName: buffered.queue ?? undefined,
110-
machinePreset: buffered.machinePreset ?? undefined,
111-
},
112-
children: [],
113-
},
114-
},
115-
},
116-
{ status: 200 }
117-
);
85+
// The helper synthesises a single root span that satisfies the SDK's
86+
// RetrieveRunTraceResponseBody schema (rootSpan is non-nullable) and
87+
// reflects the buffered terminal state.
88+
return json(buildSyntheticTraceBody(resolved.run), { status: 200 });
11889
}
11990

12091
const run = resolved.run;

apps/webapp/app/routes/api.v1.runs.$runParam.attempts.ts

Lines changed: 0 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
3-
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
43
import { z } from "zod";
5-
import { $replica } from "~/db.server";
64
import { authenticateApiRequest } from "~/services/apiAuth.server";
75
import { logger } from "~/services/logger.server";
8-
import {
9-
anyResource,
10-
createLoaderApiRoute,
11-
} from "~/services/routeBuilders/apiBuilder.server";
12-
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
136
import { ServiceValidationError } from "~/v3/services/baseService.server";
147
import { CreateTaskRunAttemptService } from "~/v3/services/createTaskRunAttempt.server";
158

@@ -18,87 +11,6 @@ const ParamsSchema = z.object({
1811
runParam: z.string(),
1912
});
2013

21-
// GET handler added to fix the pre-existing route bug where this URL
22-
// returned a Remix "no loader" 400 with an internal error message — only
23-
// `action` (POST creates a new attempt) was exported, so any
24-
// well-intentioned SDK probe hit the framework error instead of a proper
25-
// API response.
26-
//
27-
// Returns `{ attempts: [] }` for both PG and buffered runs. The detailed
28-
// attempt list belongs on the v3 retrieve endpoint, not here — this is
29-
// the dual of the POST that creates attempts, and the empty-list shape
30-
// gives the parity script a stable contract to assert against.
31-
//
32-
// Built with createLoaderApiRoute so it matches the sibling read routes
33-
// (spans, trace, retrieve): it accepts JWTs (`allowJWT`) with the same
34-
// run/task/tag/batch resource scoping, and a not-found run returns 404
35-
// with `x-should-retry: true` (`shouldRetryNotFound`) so SDK pollers keep
36-
// retrying a run that the drainer hasn't materialised yet. PG-first then
37-
// buffer fallback, so a third party can't distinguish "exists" from
38-
// "doesn't exist" cross-environment.
39-
type ResolvedRun =
40-
| { source: "pg"; run: NonNullable<Awaited<ReturnType<typeof findPgRun>>> }
41-
| { source: "buffer"; run: NonNullable<Awaited<ReturnType<typeof findRunByIdWithMollifierFallback>>> };
42-
43-
async function findPgRun(runId: string, environmentId: string) {
44-
return $replica.taskRun.findFirst({
45-
where: { friendlyId: runId, runtimeEnvironmentId: environmentId },
46-
select: { friendlyId: true, taskIdentifier: true, runTags: true, batchId: true },
47-
});
48-
}
49-
50-
export const loader = createLoaderApiRoute(
51-
{
52-
params: ParamsSchema,
53-
allowJWT: true,
54-
corsStrategy: "all",
55-
findResource: async (params, auth): Promise<ResolvedRun | null> => {
56-
const pgRun = await findPgRun(params.runParam, auth.environment.id);
57-
if (pgRun) return { source: "pg", run: pgRun };
58-
59-
const buffered = await findRunByIdWithMollifierFallback({
60-
runId: params.runParam,
61-
environmentId: auth.environment.id,
62-
organizationId: auth.environment.organizationId,
63-
});
64-
if (buffered) return { source: "buffer", run: buffered };
65-
66-
return null;
67-
},
68-
shouldRetryNotFound: true,
69-
authorization: {
70-
action: "read",
71-
resource: (resolved) => {
72-
if (resolved.source === "pg") {
73-
const run = resolved.run;
74-
const resources = [
75-
{ type: "runs", id: run.friendlyId },
76-
{ type: "tasks", id: run.taskIdentifier },
77-
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
78-
];
79-
if (run.batchId) {
80-
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
81-
}
82-
return anyResource(resources);
83-
}
84-
const run = resolved.run;
85-
const resources = [
86-
{ type: "runs", id: run.friendlyId },
87-
...(run.taskIdentifier ? [{ type: "tasks", id: run.taskIdentifier }] : []),
88-
...run.tags.map((tag) => ({ type: "tags", id: tag })),
89-
];
90-
if (run.batchId) {
91-
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
92-
}
93-
return anyResource(resources);
94-
},
95-
},
96-
},
97-
async () => {
98-
return json({ attempts: [] }, { status: 200 });
99-
}
100-
);
101-
10214
export async function action({ request, params }: ActionFunctionArgs) {
10315
// Authenticate the request
10416
const authenticationResult = await authenticateApiRequest(request);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { SyntheticRun } from "./readFallback.server";
2+
3+
// Buffered runs have no execution data — the drainer hasn't materialised
4+
// the PG row and the worker hasn't started. The SDK-facing read routes
5+
// still need to return a span/trace shape that satisfies their response
6+
// schemas; these helpers build that minimal shape from the buffered
7+
// SyntheticRun.
8+
//
9+
// CANCELED and FAILED are terminal states: a FAILED buffered run is
10+
// errored (drainer exhausted retries or the gate rejected it) and must
11+
// not signal "still in progress." The flags below mirror
12+
// syntheticTrace.server.ts so the SDK contract stays consistent across
13+
// the three read paths (spans, trace, dashboard trace presenter).
14+
15+
function deriveTerminalFlags(status: SyntheticRun["status"]): {
16+
isError: boolean;
17+
isPartial: boolean;
18+
isCancelled: boolean;
19+
} {
20+
const isCancelled = status === "CANCELED";
21+
const isFailed = status === "FAILED";
22+
return {
23+
isError: isFailed,
24+
isPartial: !isCancelled && !isFailed,
25+
isCancelled,
26+
};
27+
}
28+
29+
// Body for GET /api/v1/runs/:runId/spans/:spanId when the run is buffered
30+
// and `:spanId` has already been verified against `buffered.spanId` by the
31+
// route. Pure function so the route layer just authenticates, resolves
32+
// the run, validates the spanId, and forwards the buffered run here.
33+
export function buildSyntheticSpanDetailBody(buffered: SyntheticRun) {
34+
const flags = deriveTerminalFlags(buffered.status);
35+
return {
36+
spanId: buffered.spanId,
37+
parentId: buffered.parentSpanId ?? null,
38+
runId: buffered.friendlyId,
39+
message: buffered.taskIdentifier ?? "",
40+
...flags,
41+
level: "TRACE" as const,
42+
startTime: buffered.createdAt,
43+
durationMs: 0,
44+
};
45+
}
46+
47+
// Body for GET /api/v1/runs/:runId/trace when the run is buffered.
48+
// Returns the `{ trace: { traceId, rootSpan } }` envelope expected by the
49+
// SDK's RetrieveRunTraceResponseBody schema.
50+
export function buildSyntheticTraceBody(buffered: SyntheticRun) {
51+
const flags = deriveTerminalFlags(buffered.status);
52+
return {
53+
trace: {
54+
traceId: buffered.traceId ?? "",
55+
rootSpan: {
56+
id: buffered.spanId ?? "",
57+
runId: buffered.friendlyId,
58+
data: {
59+
message: buffered.taskIdentifier ?? "",
60+
taskSlug: buffered.taskIdentifier ?? undefined,
61+
events: [] as unknown[],
62+
startTime: buffered.createdAt,
63+
duration: 0,
64+
...flags,
65+
level: "TRACE" as const,
66+
queueName: buffered.queue ?? undefined,
67+
machinePreset: buffered.machinePreset ?? undefined,
68+
},
69+
children: [] as unknown[],
70+
},
71+
},
72+
};
73+
}

0 commit comments

Comments
 (0)