Skip to content

Commit 3453618

Browse files
d-csclaude
andcommitted
fix(webapp): align buffered API responses with existing SDK schemas
A broader audit of every public API route's buffered branch found a handful of schema-drift bugs the SDK would reject on existing clients: - /api/v1/runs/{id}/spans/{spanId} returned `parentId: undefined` (omitted in JSON). Schema declares `parentId: z.string().nullable()` — present-but-null is required. Send `null` explicitly. Also reflect the snapshot's cancelled state in `isPartial` / `isCancelled`. - /api/v1/runs/{id}/reschedule's buffered branch returned a stripped `{ id, delayUntil }`. The SDK's `rescheduleRun` validates against the full `RetrieveRunResponse` shape. Route the buffered response through the same ApiRetrieveRunPresenter the PG branch uses (which falls back to the buffer for synthetic runs). Allows `synthesisedResponse` in `mutateWithFallback` to be async. - ApiRetrieveRunPresenter.synthesiseFoundRunFromBuffer ignored the snapshot's `cancelledAt` and `delayUntil`. Status was hardcoded to `PENDING` regardless of cancellation; `completedAt` and `delayUntil` were always `null`. SDK callers (and the MCP cancel_run helper) reported status as Queued after a successful cancel. Map the synthetic status through a small switch so CANCELED, SYSTEM_FAILURE and PENDING all surface correctly. - Add `delayUntil` to SyntheticRun so set_delay reschedule patches survive the next retrieve. Mirror it onto the dashboard SpanRun synthesiser too. Verified end-to-end by replaying every public-API method against a buffered run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 49b9d00 commit 3453618

6 files changed

Lines changed: 54 additions & 35 deletions

File tree

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,19 @@ function resolveTriggerFunction(run: CommonRelatedRun): TriggerFunction {
523523
// yet, so every field that comes from execution state (output, attempts,
524524
// completedAt, cost, relations) takes a default. The presenter's call()
525525
// handles QUEUED-state runs without surprise.
526+
function bufferedStatusToTaskRunStatus(status: SyntheticRun["status"]): TaskRunStatus {
527+
switch (status) {
528+
case "FAILED":
529+
return "SYSTEM_FAILURE";
530+
case "CANCELED":
531+
return "CANCELED";
532+
default:
533+
return "PENDING";
534+
}
535+
}
536+
526537
function synthesiseFoundRunFromBuffer(buffered: SyntheticRun): FoundRun {
527-
const status: TaskRunStatus =
528-
buffered.status === "FAILED" ? "SYSTEM_FAILURE" : "PENDING";
538+
const status: TaskRunStatus = bufferedStatusToTaskRunStatus(buffered.status);
529539

530540
const errorJson: Prisma.JsonValue = buffered.error
531541
? {
@@ -544,10 +554,10 @@ function synthesiseFoundRunFromBuffer(buffered: SyntheticRun): FoundRun {
544554
taskIdentifier: buffered.taskIdentifier ?? "",
545555
createdAt: buffered.createdAt,
546556
startedAt: null,
547-
updatedAt: buffered.createdAt,
548-
completedAt: null,
557+
updatedAt: buffered.cancelledAt ?? buffered.createdAt,
558+
completedAt: buffered.cancelledAt ?? null,
549559
expiredAt: null,
550-
delayUntil: null,
560+
delayUntil: buffered.delayUntil ?? null,
551561
metadata,
552562
metadataType: buffered.metadataType ?? "application/json",
553563
ttl: buffered.ttl ?? null,

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,15 @@ export const loader = createLoaderApiRoute(
8989
return json(
9090
{
9191
spanId: resolved.run.spanId,
92-
parentId: resolved.run.parentSpanId,
92+
parentId: resolved.run.parentSpanId ?? null,
9393
runId: resolved.run.friendlyId,
9494
message: resolved.run.taskIdentifier ?? "",
9595
isError: false,
96-
isPartial: true,
97-
isCancelled: false,
96+
isPartial: resolved.run.status !== "CANCELED",
97+
isCancelled: resolved.run.status === "CANCELED",
9898
level: "TRACE",
9999
startTime: resolved.run.createdAt,
100100
durationMs: 0,
101-
properties: undefined,
102-
events: undefined,
103-
entityType: undefined,
104-
ai: undefined,
105-
triggeredRuns: undefined,
106101
},
107102
{ status: 200 }
108103
);

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,26 @@ export async function action({ request, params }: ActionFunctionArgs) {
7676
}
7777
return json(result);
7878
},
79-
// Buffered snapshot has been patched. Synthesise a minimal
80-
// retrieve-shape response — the run hasn't materialised yet, so
81-
// the presenter's full pass would synthesise mostly defaults
82-
// anyway. Returning the friendlyId + the new delay is sufficient
83-
// for SDK confirmation; subsequent retrieve calls go through the
84-
// existing presenter with read-fallback (Phase A).
85-
synthesisedResponse: () =>
86-
json(
87-
{
88-
id: parsed.data.runParam,
89-
delayUntil: delayUntil.toISOString(),
90-
},
91-
{ status: 200 }
92-
),
79+
// Buffered snapshot has been patched. Run it through the same
80+
// ApiRetrieveRunPresenter the PG branch uses (it falls back to
81+
// the buffer for the SyntheticRun lookup) so the response shape
82+
// matches `RetrieveRunResponse` — that's what the SDK's
83+
// `rescheduleRun` zod-validates against. Returning a stripped
84+
// `{ id, delayUntil }` object fails the SDK schema on every
85+
// existing SDK version.
86+
synthesisedResponse: async () => {
87+
const run = await ApiRetrieveRunPresenter.findRun(parsed.data.runParam, env);
88+
if (!run) {
89+
return json({ error: "Run not found" }, { status: 404 });
90+
}
91+
const apiVersion = getApiVersion(request);
92+
const presenter = new ApiRetrieveRunPresenter(apiVersion);
93+
const result = await presenter.call(run, env);
94+
if (!result) {
95+
return json({ error: "Run not found" }, { status: 404 });
96+
}
97+
return json(result);
98+
},
9399
abortSignal: getRequestAbortSignal(),
94100
});
95101

apps/webapp/app/v3/mollifier/mutateWithFallback.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type MutateWithFallbackInput<TResponse> = {
2323
pgMutation: (pgRow: TaskRun) => Promise<TResponse>;
2424
// Called when the patch landed cleanly on the buffer snapshot. The
2525
// drainer will see the patched payload on its next pop.
26-
synthesisedResponse: () => TResponse;
26+
synthesisedResponse: () => TResponse | Promise<TResponse>;
2727
abortSignal?: AbortSignal;
2828
// Override defaults for tests.
2929
safetyNetMs?: number;
@@ -77,7 +77,7 @@ export async function mutateWithFallback<TResponse>(
7777
);
7878

7979
if (result === "applied_to_snapshot") {
80-
return { kind: "snapshot", response: input.synthesisedResponse() };
80+
return { kind: "snapshot", response: await input.synthesisedResponse() };
8181
}
8282

8383
if (result === "not_found") {

apps/webapp/app/v3/mollifier/readFallback.server.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export type SyntheticRun = {
2525
// the drainer materialises it.
2626
cancelledAt: Date | undefined;
2727
cancelReason: string | undefined;
28+
// Reschedule patch (`set_delay`) writes `delayUntil` into the snapshot.
29+
// Surfacing it on SyntheticRun lets the retrieve-run shape reflect the
30+
// pending delay before the drainer materialises the PG row.
31+
delayUntil: Date | undefined;
2832
taskIdentifier: string | undefined;
2933
createdAt: Date;
3034

@@ -133,18 +137,22 @@ export async function findRunByIdWithMollifierFallback(
133137
const cancelledAtRaw = asString(snapshot.cancelledAt);
134138
const cancelledAt = cancelledAtRaw ? new Date(cancelledAtRaw) : undefined;
135139
const cancelReason = asString(snapshot.cancelReason);
136-
const status: SyntheticRun["status"] = cancelledAt
137-
? "CANCELED"
138-
: entry.status === "FAILED"
139-
? "FAILED"
140-
: "QUEUED";
140+
let status: SyntheticRun["status"] = "QUEUED";
141+
if (cancelledAt) {
142+
status = "CANCELED";
143+
} else if (entry.status === "FAILED") {
144+
status = "FAILED";
145+
}
146+
const delayUntilRaw = asString(snapshot.delayUntil);
147+
const delayUntil = delayUntilRaw ? new Date(delayUntilRaw) : undefined;
141148

142149
return {
143150
id: RunId.fromFriendlyId(entry.runId),
144151
friendlyId: entry.runId,
145152
status,
146153
cancelledAt,
147154
cancelReason,
155+
delayUntil,
148156
taskIdentifier: asString(snapshot.taskIdentifier),
149157
createdAt: entry.createdAt,
150158

apps/webapp/app/v3/mollifier/syntheticSpanRun.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export async function buildSyntheticSpanRun(args: {
6262
startedAt: null,
6363
executedAt: null,
6464
updatedAt: run.cancelledAt ?? run.createdAt,
65-
delayUntil: null,
65+
delayUntil: run.delayUntil ?? null,
6666
expiredAt: null,
6767
completedAt: run.cancelledAt ?? null,
6868
logsDeletedAt: null,

0 commit comments

Comments
 (0)