From ce895cae4300d44c20a38923b3172cb40544e341 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 20 May 2026 18:11:55 +0100 Subject: [PATCH 1/5] fix(webapp,sdk): keep chat.agent snapshots on one object store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a webapp is configured with both a default object store (`OBJECT_STORE_BASE_URL`) and a named protocol provider (`OBJECT_STORE_DEFAULT_PROTOCOL=s3`), chat.agent session snapshot writes landed in the named provider but reads fell through to the default — so the recovery boot couldn't find the snapshot it had just written. After a mid-stream cancel, the missing snapshot triggered a fallback replay path that dropped the user's follow-up message, leaving the chat stuck in `submitted` indefinitely. Fix: - New `/api/v1/sessions/:id/snapshot-url` route handles PUT + GET symmetrically — both prefix unprefixed keys with `OBJECT_STORE_DEFAULT_PROTOCOL` so they always round-trip through the same store. - `Session.chatSnapshotStoragePath` persists the resolved URI on first write so future protocol changes don't strand existing snapshots. Reads prefer the stored URI and fall back to the computed default for pre-column sessions. - SDK calls `createChatSnapshotUploadUrl` / `getChatSnapshotUrl`; the generic v1/v2 packets endpoints are unchanged. --- .../chat-snapshot-default-protocol.md | 8 ++ ...api.v1.sessions.$sessionId.snapshot-url.ts | 99 +++++++++++++++++++ .../app/services/apiRateLimit.server.ts | 1 + .../migration.sql | 1 + .../database/prisma/schema.prisma | 5 + packages/core/src/v3/apiClient/index.ts | 26 +++++ packages/trigger-sdk/src/v3/ai.ts | 18 +--- .../trigger-sdk/test/chat-snapshot.test.ts | 68 +++++++------ 8 files changed, 175 insertions(+), 51 deletions(-) create mode 100644 .server-changes/chat-snapshot-default-protocol.md create mode 100644 apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts create mode 100644 internal-packages/database/prisma/migrations/20260520170000_add_session_chat_snapshot_storage_path/migration.sql diff --git a/.server-changes/chat-snapshot-default-protocol.md b/.server-changes/chat-snapshot-default-protocol.md new file mode 100644 index 00000000000..e9ceae7a15a --- /dev/null +++ b/.server-changes/chat-snapshot-default-protocol.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: fix +--- + +Pin chat.agent session snapshots to a single object store so writes and reads +always round-trip through the same provider when `OBJECT_STORE_DEFAULT_PROTOCOL` +is set. diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts new file mode 100644 index 00000000000..bdbaf7950b9 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -0,0 +1,99 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { authenticateApiRequest } from "~/services/apiAuth.server"; +import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; + +const ParamsSchema = z.object({ + sessionId: z.string(), +}); + +// Canonical key for new sessions, prefixed with the default protocol so +// PUT/GET resolve to the same store on multi-provider deployments. +function defaultSnapshotKey(sessionId: string): string { + const path = `sessions/${sessionId}/snapshot.json`; + const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; + return protocol ? `${protocol}://${path}` : path; +} + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "PUT") { + return { status: 405, body: "Method Not Allowed" }; + } + + const auth = await authenticateApiRequest(request); + if (!auth) { + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const parsed = ParamsSchema.parse(params); + const session = await resolveSessionByIdOrExternalId( + $replica, + auth.environment.id, + parsed.sessionId + ); + if (!session) { + return json({ error: "Session not found" }, { status: 404 }); + } + + // Reuse the stored path on subsequent writes; persist on first write so + // future default-protocol changes don't strand existing snapshots. + const key = session.chatSnapshotStoragePath ?? defaultSnapshotKey(parsed.sessionId); + + const signed = await generatePresignedUrl( + auth.environment.project.externalRef, + auth.environment.slug, + key, + "PUT" + ); + if (!signed.success) { + return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + } + + if (session.chatSnapshotStoragePath === null) { + await prisma.session + .updateMany({ + where: { id: session.id, chatSnapshotStoragePath: null }, + data: { chatSnapshotStoragePath: key }, + }) + .catch(() => { + // Best-effort; concurrent writers may have already set it. + }); + } + + return json({ presignedUrl: signed.url }); +} + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, auth) => + resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId), + }, + async ({ params, authentication, resource: session }) => { + if (!session) { + return json({ error: "Session not found" }, { status: 404 }); + } + + // Stored path wins; fall back to computed default for pre-column sessions. + const key = session.chatSnapshotStoragePath ?? defaultSnapshotKey(params.sessionId); + + const signed = await generatePresignedUrl( + authentication.environment.project.externalRef, + authentication.environment.slug, + key, + "GET" + ); + if (!signed.success) { + return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + } + + return json({ presignedUrl: signed.url }); + } +); diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 3618806fce7..4621146bd66 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -70,6 +70,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ // customer-facing surface so customer rate limits shouldn't apply. /^\/api\/v1\/packets\//, /^\/api\/v2\/packets\//, + /^\/api\/v1\/sessions\/[^\/]+\/snapshot-url$/, ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", diff --git a/internal-packages/database/prisma/migrations/20260520170000_add_session_chat_snapshot_storage_path/migration.sql b/internal-packages/database/prisma/migrations/20260520170000_add_session_chat_snapshot_storage_path/migration.sql new file mode 100644 index 00000000000..c61c7ac5726 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260520170000_add_session_chat_snapshot_storage_path/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Session" ADD COLUMN IF NOT EXISTS "chatSnapshotStoragePath" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7e32a96d805..b986e04e1ac 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -830,6 +830,11 @@ model Session { /// (OSS, or pre-backfill); reads fall back to the global basin. streamBasinName String? + /// Storage URI (with protocol prefix) for this session's chat.agent + /// snapshot blob. Set on first snapshot write. Null = pre-column session, + /// fall back to computed default path. + chatSnapshotStoragePath String? + runs SessionRun[] /// Idempotency: `(env, externalId)` uniquely identifies a session. diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 64472a349ba..4bbeca8bd31 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -602,6 +602,32 @@ export class ApiClient { ); } + /** Presigned PUT URL for a `chat.agent` session snapshot. */ + createChatSnapshotUploadUrl(sessionId: string, requestOptions?: ZodFetchOptions) { + return zodfetch( + CreateUploadPayloadUrlResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`, + { + method: "PUT", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + /** Presigned GET URL for a `chat.agent` session snapshot. */ + getChatSnapshotUrl(sessionId: string, requestOptions?: ZodFetchOptions) { + return zodfetch( + CreateUploadPayloadUrlResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + retrieveRun(runId: string, requestOptions?: ZodFetchOptions) { return zodfetch( RetrieveRunResponse, diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index a8a1b574f65..72df9d92422 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -219,20 +219,6 @@ async function findLatestSessionInCursor( */ export type { ChatSnapshotV1 } from "@trigger.dev/core/v3"; -/** - * S3 key suffix for a session's snapshot blob. The webapp's presigned-URL - * routes prefix this with `packets/{projectRef}/{envSlug}/` server-side, so - * the final S3 key lands at - * `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json`. - * - * Stable per session: the friendlyId persists across `chat.requestUpgrade` - * continuations and idle-suspend restarts. - * @internal - */ -function snapshotFilename(sessionId: string): string { - return `sessions/${sessionId}/snapshot.json`; -} - /** * Test-only override hook — `mockChatAgent` installs a fake to return * synthetic snapshots without hitting S3. Mirrors the `__set*ImplForTests` @@ -285,7 +271,7 @@ async function readChatSnapshot( const apiClient = apiClientManager.clientOrThrow(); let presignedUrl: string; try { - const resp = await apiClient.getPayloadUrl(snapshotFilename(sessionId)); + const resp = await apiClient.getChatSnapshotUrl(sessionId); presignedUrl = resp.presignedUrl; } catch (error) { logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", { @@ -360,7 +346,7 @@ async function writeChatSnapshot( const apiClient = apiClientManager.clientOrThrow(); let presignedUrl: string; try { - const resp = await apiClient.createUploadPayloadUrl(snapshotFilename(sessionId)); + const resp = await apiClient.createChatSnapshotUploadUrl(sessionId); presignedUrl = resp.presignedUrl; } catch (error) { logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", { diff --git a/packages/trigger-sdk/test/chat-snapshot.test.ts b/packages/trigger-sdk/test/chat-snapshot.test.ts index cac3364639b..85e1fe8adef 100644 --- a/packages/trigger-sdk/test/chat-snapshot.test.ts +++ b/packages/trigger-sdk/test/chat-snapshot.test.ts @@ -34,28 +34,27 @@ function buildSnapshot(count = 1): ChatSnapshotV1 { /** * Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API - * client whose `getPayloadUrl` / `createUploadPayloadUrl` resolve with the - * presigned URLs the test wants. Returns spies for assertion. + * client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve + * with the presigned URLs the test wants. Returns spies for assertion. */ function stubApiClient(opts: { - getPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>; - createUploadPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>; + getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>; + createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>; }) { - const getPayloadUrl = vi.fn( - opts.getPayloadUrl ?? (async (_filename: string) => ({ presignedUrl: "https://example.invalid/get" })) + const getChatSnapshotUrl = vi.fn( + opts.getChatSnapshotUrl ?? + (async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" })) ); - const createUploadPayloadUrl = vi.fn( - opts.createUploadPayloadUrl ?? - (async (_filename: string) => ({ presignedUrl: "https://example.invalid/put" })) + const createChatSnapshotUploadUrl = vi.fn( + opts.createChatSnapshotUploadUrl ?? + (async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" })) ); const fakeClient = { - getPayloadUrl, - createUploadPayloadUrl, + getChatSnapshotUrl, + createChatSnapshotUploadUrl, }; - vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue( - fakeClient as never - ); - return { getPayloadUrl, createUploadPayloadUrl }; + vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never); + return { getChatSnapshotUrl, createChatSnapshotUploadUrl }; } /** @@ -87,7 +86,7 @@ describe("chat snapshot helpers", () => { describe("readChatSnapshot", () => { it("returns the snapshot on a successful GET", async () => { - const { getPayloadUrl } = stubApiClient({}); + const { getChatSnapshotUrl } = stubApiClient({}); const snapshot = buildSnapshot(2); stubFetch(async () => new Response(JSON.stringify(snapshot), { @@ -97,7 +96,7 @@ describe("chat snapshot helpers", () => { ); const result = await readChatSnapshot("session-1"); - expect(getPayloadUrl).toHaveBeenCalledWith("sessions/session-1/snapshot.json"); + expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1"); expect(result).toMatchObject({ version: 1, messages: snapshot.messages, @@ -177,7 +176,7 @@ describe("chat snapshot helpers", () => { it("returns undefined when presign call fails", async () => { stubApiClient({ - getPayloadUrl: async () => { + getChatSnapshotUrl: async () => { throw new Error("presign denied"); }, }); @@ -202,13 +201,13 @@ describe("chat snapshot helpers", () => { describe("writeChatSnapshot", () => { it("PUTs the snapshot JSON to the presigned URL", async () => { - const { createUploadPayloadUrl } = stubApiClient({}); + const { createChatSnapshotUploadUrl } = stubApiClient({}); const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); const snapshot = buildSnapshot(3); await writeChatSnapshot("session-2", snapshot); - expect(createUploadPayloadUrl).toHaveBeenCalledWith("sessions/session-2/snapshot.json"); + expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2"); expect(fetchSpy).toHaveBeenCalledOnce(); const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe("https://example.invalid/put"); @@ -239,7 +238,7 @@ describe("chat snapshot helpers", () => { it("returns without throwing when presign fails (warns)", async () => { stubApiClient({ - createUploadPayloadUrl: async () => { + createChatSnapshotUploadUrl: async () => { throw new Error("presign denied"); }, }); @@ -250,29 +249,28 @@ describe("chat snapshot helpers", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it("uses the same `snapshotFilename(sessionId)` convention as the read path", async () => { - // Round-trip check: read and write target the same key for a given - // sessionId. The runtime relies on this to make read-after-write - // coherent on subsequent boots. - const { getPayloadUrl } = stubApiClient({ - getPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/get" }), + it("addresses reads and writes by the same sessionId", async () => { + // Round-trip check: both presign methods receive the same sessionId. + // The canonical key (`sessions/{id}/snapshot.json`) lives server-side + // now, so the SDK has no key string to compare — sessionId equality + // is the SDK-visible invariant. + const { getChatSnapshotUrl } = stubApiClient({ + getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }), }); stubFetch(async () => new Response(null, { status: 404 })); - // Trigger a read. await readChatSnapshot("round-trip-session"); - const [readKey] = getPayloadUrl.mock.calls[0]!; + const [readArg] = getChatSnapshotUrl.mock.calls[0]!; - // Trigger a write to the same session. - const { createUploadPayloadUrl } = stubApiClient({ - createUploadPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }), + const { createChatSnapshotUploadUrl } = stubApiClient({ + createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }), }); stubFetch(async () => new Response(null, { status: 200 })); await writeChatSnapshot("round-trip-session", buildSnapshot()); - const [writeKey] = createUploadPayloadUrl.mock.calls[0]!; + const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!; - expect(readKey).toBe(writeKey); - expect(readKey).toBe("sessions/round-trip-session/snapshot.json"); + expect(readArg).toBe(writeArg); + expect(readArg).toBe("round-trip-session"); }); }); }); From f5db19a27d57953807f8cbdbebcdec62b2e1fa70 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 20 May 2026 18:41:00 +0100 Subject: [PATCH 2/5] fix(webapp): stamp Session.chatSnapshotStoragePath at row creation Address CodeRabbit findings on PR #3679: - Stamp `chatSnapshotStoragePath` once at session create + upsert-create paths so the new presign route only reads. Drops the lazy backfill branch (and its empty `.catch()`) from the route. - Return `json(...)` for the 405 in the action handler so Remix doesn't coerce the plain object into a 200 with a misleading body. - Extract `chatSnapshotStoragePathForSession(friendlyId)` next to the other session-addressing helpers so creation and the GET fallback agree on the canonical path. --- ...api.v1.sessions.$sessionId.snapshot-url.ts | 46 ++++++------------- apps/webapp/app/routes/api.v1.sessions.ts | 7 ++- .../app/services/realtime/sessions.server.ts | 14 ++++++ 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index bdbaf7950b9..e88c32dfc34 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -1,10 +1,12 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { $replica, prisma } from "~/db.server"; -import { env } from "~/env.server"; +import { $replica } from "~/db.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; +import { + chatSnapshotStoragePathForSession, + resolveSessionByIdOrExternalId, +} from "~/services/realtime/sessions.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; @@ -12,17 +14,17 @@ const ParamsSchema = z.object({ sessionId: z.string(), }); -// Canonical key for new sessions, prefixed with the default protocol so -// PUT/GET resolve to the same store on multi-provider deployments. -function defaultSnapshotKey(sessionId: string): string { - const path = `sessions/${sessionId}/snapshot.json`; - const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; - return protocol ? `${protocol}://${path}` : path; +// `chatSnapshotStoragePath` is stamped on every new Session at row creation +// (see api.v1.sessions.ts). The fallback handles sessions created before +// the column existed — read against the currently-configured default +// protocol and compute the same path the SDK uploaded under. +function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: string | null }) { + return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId); } export async function action({ request, params }: ActionFunctionArgs) { if (request.method.toUpperCase() !== "PUT") { - return { status: 405, body: "Method Not Allowed" }; + return json({ error: "Method Not Allowed" }, { status: 405 }); } const auth = await authenticateApiRequest(request); @@ -40,31 +42,16 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Session not found" }, { status: 404 }); } - // Reuse the stored path on subsequent writes; persist on first write so - // future default-protocol changes don't strand existing snapshots. - const key = session.chatSnapshotStoragePath ?? defaultSnapshotKey(parsed.sessionId); - const signed = await generatePresignedUrl( auth.environment.project.externalRef, auth.environment.slug, - key, + snapshotKey(session), "PUT" ); if (!signed.success) { return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); } - if (session.chatSnapshotStoragePath === null) { - await prisma.session - .updateMany({ - where: { id: session.id, chatSnapshotStoragePath: null }, - data: { chatSnapshotStoragePath: key }, - }) - .catch(() => { - // Best-effort; concurrent writers may have already set it. - }); - } - return json({ presignedUrl: signed.url }); } @@ -76,18 +63,15 @@ export const loader = createLoaderApiRoute( findResource: async (params, auth) => resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId), }, - async ({ params, authentication, resource: session }) => { + async ({ authentication, resource: session }) => { if (!session) { return json({ error: "Session not found" }, { status: 404 }); } - // Stored path wins; fall back to computed default for pre-column sessions. - const key = session.chatSnapshotStoragePath ?? defaultSnapshotKey(params.sessionId); - const signed = await generatePresignedUrl( authentication.environment.project.externalRef, authentication.environment.slug, - key, + snapshotKey(session), "GET" ); if (!signed.success) { diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 591a9fe5319..9f3425fde94 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -17,7 +17,10 @@ import { ensureRunForSession, type SessionTriggerConfig, } from "~/services/realtime/sessionRunManager.server"; -import { serializeSession } from "~/services/realtime/sessions.server"; +import { + chatSnapshotStoragePathForSession, + serializeSession, +} from "~/services/realtime/sessions.server"; import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server"; import { anyResource, @@ -181,6 +184,7 @@ const { action } = createActionApiRoute( environmentType: authentication.environment.type, organizationId: authentication.environment.organizationId, streamBasinName: authentication.environment.organization.streamBasinName, + chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId), }, update: { triggerConfig: triggerConfigJson }, }); @@ -201,6 +205,7 @@ const { action } = createActionApiRoute( environmentType: authentication.environment.type, organizationId: authentication.environment.organizationId, streamBasinName: authentication.environment.organization.streamBasinName, + chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId), }, }); } diff --git a/apps/webapp/app/services/realtime/sessions.server.ts b/apps/webapp/app/services/realtime/sessions.server.ts index 594d417292c..5640b14d8f0 100644 --- a/apps/webapp/app/services/realtime/sessions.server.ts +++ b/apps/webapp/app/services/realtime/sessions.server.ts @@ -1,6 +1,20 @@ import type { PrismaClient, Session } from "@trigger.dev/database"; import type { SessionItem } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; +import { env } from "~/env.server"; + +/** + * Canonical storage URI for a session's chat.agent snapshot. Stamped on + * `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns + * resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL` + * changes later. The protocol prefix (when set) is what makes + * generic-packets PUT and GET agree on the target provider. + */ +export function chatSnapshotStoragePathForSession(friendlyId: string): string { + const path = `sessions/${friendlyId}/snapshot.json`; + const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; + return protocol ? `${protocol}://${path}` : path; +} /** * Prefix that {@link SessionId.generate} attaches to every Session friendlyId. From b589739850da6f27a8f8db6509b8f0229e8ecacf Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 20 May 2026 18:51:26 +0100 Subject: [PATCH 3/5] fix(webapp): migrate snapshot-url PUT to createActionApiRoute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Devin findings on PR #3679: - Migrate the PUT action handler from the deprecated `authenticateApiRequest` to `createActionApiRoute` so it picks up `allowJWT: true` symmetrically with the GET loader. Without this, the SDK's run-scoped JWT (which is how chat.agent workers authenticate against the API) was rejected on every snapshot upload — the PR worked in tests against the dev API key but would have 401'd in real run-scoped contexts. - Both PUT and GET now go through the same builder pattern with the same config (`findResource`, CORS, JWT), eliminating the style drift the initial implementation introduced. --- ...api.v1.sessions.$sessionId.snapshot-url.ts | 76 ++++++++----------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index e88c32dfc34..3a291a54205 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -1,13 +1,14 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { authenticateApiRequest } from "~/services/apiAuth.server"; import { chatSnapshotStoragePathForSession, resolveSessionByIdOrExternalId, } from "~/services/realtime/sessions.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; const ParamsSchema = z.object({ @@ -22,47 +23,16 @@ function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: str return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId); } -export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "PUT") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const auth = await authenticateApiRequest(request); - if (!auth) { - return json({ error: "Invalid or Missing API key" }, { status: 401 }); - } - - const parsed = ParamsSchema.parse(params); - const session = await resolveSessionByIdOrExternalId( - $replica, - auth.environment.id, - parsed.sessionId - ); - if (!session) { - return json({ error: "Session not found" }, { status: 404 }); - } +const routeConfig = { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all" as const, + findResource: async (params: z.infer, auth: { environment: { id: string } }) => + resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId), +}; - const signed = await generatePresignedUrl( - auth.environment.project.externalRef, - auth.environment.slug, - snapshotKey(session), - "PUT" - ); - if (!signed.success) { - return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); - } - - return json({ presignedUrl: signed.url }); -} - -export const loader = createLoaderApiRoute( - { - params: ParamsSchema, - allowJWT: true, - corsStrategy: "all", - findResource: async (params, auth) => - resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId), - }, +export const { action } = createActionApiRoute( + { ...routeConfig, method: "PUT" }, async ({ authentication, resource: session }) => { if (!session) { return json({ error: "Session not found" }, { status: 404 }); @@ -72,7 +42,7 @@ export const loader = createLoaderApiRoute( authentication.environment.project.externalRef, authentication.environment.slug, snapshotKey(session), - "GET" + "PUT" ); if (!signed.success) { return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); @@ -81,3 +51,21 @@ export const loader = createLoaderApiRoute( return json({ presignedUrl: signed.url }); } ); + +export const loader = createLoaderApiRoute(routeConfig, async ({ authentication, resource: session }) => { + if (!session) { + return json({ error: "Session not found" }, { status: 404 }); + } + + const signed = await generatePresignedUrl( + authentication.environment.project.externalRef, + authentication.environment.slug, + snapshotKey(session), + "GET" + ); + if (!signed.success) { + return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + } + + return json({ presignedUrl: signed.url }); +}); From fd5a7e2268dffb4d72c3419da5d1ff920d373a0c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 20 May 2026 19:07:13 +0100 Subject: [PATCH 4/5] test(webapp): update chat snapshot integration stubs for new apiClient methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-snapshot-integration and replay-after-crash MinIO integration tests stubbed the old `getPayloadUrl` / `createUploadPayloadUrl` methods on the apiClient. The SDK now calls `getChatSnapshotUrl` / `createChatSnapshotUploadUrl`, so the stubs return undefined and every test in those two files fails. Update the stubs to mirror the new snapshot-url route — derive the canonical key via the shared `chatSnapshotStoragePathForSession` helper and presign through it. --- .../test/chat-snapshot-integration.test.ts | 20 +++++++++---------- apps/webapp/test/replay-after-crash.test.ts | 11 ++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/webapp/test/chat-snapshot-integration.test.ts b/apps/webapp/test/chat-snapshot-integration.test.ts index 1d500e16b90..001ad81a891 100644 --- a/apps/webapp/test/chat-snapshot-integration.test.ts +++ b/apps/webapp/test/chat-snapshot-integration.test.ts @@ -26,6 +26,7 @@ import { import type { UIMessage } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; import { env } from "~/env.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/sessions.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; vi.setConfig({ testTimeout: 60_000 }); @@ -54,22 +55,21 @@ function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string } /** * Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake - * api client whose `getPayloadUrl` / `createUploadPayloadUrl` return - * presigned URLs minted by the webapp's real `generatePresignedUrl` - * (which signs against MinIO). - * - * The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write - * the blob, so MinIO ends up holding the actual bytes. + * api client. Mirrors the snapshot-url route: derive the canonical + * `sessions/{id}/snapshot.json` key (with optional default-protocol + * prefix) and sign it via `generatePresignedUrl` against MinIO. */ function stubApiClient(opts: { projectRef: string; envSlug: string }) { vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({ - async getPayloadUrl(filename: string) { - const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET"); + async getChatSnapshotUrl(sessionId: string) { + const key = chatSnapshotStoragePathForSession(sessionId); + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET"); if (!result.success) throw new Error(result.error); return { presignedUrl: result.url }; }, - async createUploadPayloadUrl(filename: string) { - const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT"); + async createChatSnapshotUploadUrl(sessionId: string) { + const key = chatSnapshotStoragePathForSession(sessionId); + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT"); if (!result.success) throw new Error(result.error); return { presignedUrl: result.url }; }, diff --git a/apps/webapp/test/replay-after-crash.test.ts b/apps/webapp/test/replay-after-crash.test.ts index 747a27eac9b..2069c7d2336 100644 --- a/apps/webapp/test/replay-after-crash.test.ts +++ b/apps/webapp/test/replay-after-crash.test.ts @@ -33,6 +33,7 @@ import { import type { UIMessageChunk } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; import { env } from "~/env.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/sessions.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; vi.setConfig({ testTimeout: 60_000 }); @@ -77,13 +78,15 @@ function stubApiClient(opts: { }) ); vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({ - async getPayloadUrl(filename: string) { - const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET"); + async getChatSnapshotUrl(sessionId: string) { + const key = chatSnapshotStoragePathForSession(sessionId); + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET"); if (!result.success) throw new Error(result.error); return { presignedUrl: result.url }; }, - async createUploadPayloadUrl(filename: string) { - const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT"); + async createChatSnapshotUploadUrl(sessionId: string) { + const key = chatSnapshotStoragePathForSession(sessionId); + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT"); if (!result.success) throw new Error(result.error); return { presignedUrl: result.url }; }, From 97085d582b4ae4c836d05fa753610a24d9adbcd0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 20 May 2026 19:20:31 +0100 Subject: [PATCH 5/5] fix(webapp): isolate chat-snapshot key helper from db.server import chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration tests for chat snapshot + replay-after-crash imported `chatSnapshotStoragePathForSession` from sessions.server.ts, which transitively pulls in `~/db.server` and constructs the singleton PrismaClient. In CI the testcontainer Postgres is on a random port, so the eager Prisma client throws `P1001: Can't reach database server at localhost:5432` as an unhandled rejection — tests pass but vitest exits non-zero. Extract the helper into its own `chatSnapshot.server.ts` module that only depends on env.server. Update the route, session create handler, and both integration tests to import from the new location. No behavior change. --- .../api.v1.sessions.$sessionId.snapshot-url.ts | 6 ++---- apps/webapp/app/routes/api.v1.sessions.ts | 6 ++---- .../app/services/realtime/chatSnapshot.server.ts | 13 +++++++++++++ .../app/services/realtime/sessions.server.ts | 14 -------------- apps/webapp/test/chat-snapshot-integration.test.ts | 2 +- apps/webapp/test/replay-after-crash.test.ts | 2 +- 6 files changed, 19 insertions(+), 24 deletions(-) create mode 100644 apps/webapp/app/services/realtime/chatSnapshot.server.ts diff --git a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts index 3a291a54205..537845d8b41 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$sessionId.snapshot-url.ts @@ -1,10 +1,8 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { - chatSnapshotStoragePathForSession, - resolveSessionByIdOrExternalId, -} from "~/services/realtime/sessions.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; +import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; import { createActionApiRoute, createLoaderApiRoute, diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 9f3425fde94..9b67c714127 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -17,10 +17,8 @@ import { ensureRunForSession, type SessionTriggerConfig, } from "~/services/realtime/sessionRunManager.server"; -import { - chatSnapshotStoragePathForSession, - serializeSession, -} from "~/services/realtime/sessions.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; +import { serializeSession } from "~/services/realtime/sessions.server"; import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server"; import { anyResource, diff --git a/apps/webapp/app/services/realtime/chatSnapshot.server.ts b/apps/webapp/app/services/realtime/chatSnapshot.server.ts new file mode 100644 index 00000000000..83db0d94197 --- /dev/null +++ b/apps/webapp/app/services/realtime/chatSnapshot.server.ts @@ -0,0 +1,13 @@ +import { env } from "~/env.server"; + +/** + * Canonical storage URI for a session's chat.agent snapshot. Stamped on + * `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns + * resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL` + * changes later. + */ +export function chatSnapshotStoragePathForSession(friendlyId: string): string { + const path = `sessions/${friendlyId}/snapshot.json`; + const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; + return protocol ? `${protocol}://${path}` : path; +} diff --git a/apps/webapp/app/services/realtime/sessions.server.ts b/apps/webapp/app/services/realtime/sessions.server.ts index 5640b14d8f0..594d417292c 100644 --- a/apps/webapp/app/services/realtime/sessions.server.ts +++ b/apps/webapp/app/services/realtime/sessions.server.ts @@ -1,20 +1,6 @@ import type { PrismaClient, Session } from "@trigger.dev/database"; import type { SessionItem } from "@trigger.dev/core/v3"; import { $replica } from "~/db.server"; -import { env } from "~/env.server"; - -/** - * Canonical storage URI for a session's chat.agent snapshot. Stamped on - * `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns - * resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL` - * changes later. The protocol prefix (when set) is what makes - * generic-packets PUT and GET agree on the target provider. - */ -export function chatSnapshotStoragePathForSession(friendlyId: string): string { - const path = `sessions/${friendlyId}/snapshot.json`; - const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL; - return protocol ? `${protocol}://${path}` : path; -} /** * Prefix that {@link SessionId.generate} attaches to every Session friendlyId. diff --git a/apps/webapp/test/chat-snapshot-integration.test.ts b/apps/webapp/test/chat-snapshot-integration.test.ts index 001ad81a891..c2a5dcce98d 100644 --- a/apps/webapp/test/chat-snapshot-integration.test.ts +++ b/apps/webapp/test/chat-snapshot-integration.test.ts @@ -26,7 +26,7 @@ import { import type { UIMessage } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; import { env } from "~/env.server"; -import { chatSnapshotStoragePathForSession } from "~/services/realtime/sessions.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; vi.setConfig({ testTimeout: 60_000 }); diff --git a/apps/webapp/test/replay-after-crash.test.ts b/apps/webapp/test/replay-after-crash.test.ts index 2069c7d2336..fdd5274b5e7 100644 --- a/apps/webapp/test/replay-after-crash.test.ts +++ b/apps/webapp/test/replay-after-crash.test.ts @@ -33,7 +33,7 @@ import { import type { UIMessageChunk } from "ai"; import { afterEach, describe, expect, vi } from "vitest"; import { env } from "~/env.server"; -import { chatSnapshotStoragePathForSession } from "~/services/realtime/sessions.server"; +import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server"; import { generatePresignedUrl } from "~/v3/objectStore.server"; vi.setConfig({ testTimeout: 60_000 });