Skip to content

Commit b16902a

Browse files
authored
Merge branch 'main' into sentry-user-attribution
2 parents c78e88f + d343727 commit b16902a

15 files changed

Lines changed: 194 additions & 68 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Pin chat.agent session snapshots to a single object store so writes and reads
7+
always round-trip through the same provider when `OBJECT_STORE_DEFAULT_PROTOCOL`
8+
is set.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Speed up env-var lookups on the projects API by indexing `EnvironmentVariableValue.environmentId`.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { $replica } from "~/db.server";
4+
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
5+
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
6+
import {
7+
createActionApiRoute,
8+
createLoaderApiRoute,
9+
} from "~/services/routeBuilders/apiBuilder.server";
10+
import { generatePresignedUrl } from "~/v3/objectStore.server";
11+
12+
const ParamsSchema = z.object({
13+
sessionId: z.string(),
14+
});
15+
16+
// `chatSnapshotStoragePath` is stamped on every new Session at row creation
17+
// (see api.v1.sessions.ts). The fallback handles sessions created before
18+
// the column existed — read against the currently-configured default
19+
// protocol and compute the same path the SDK uploaded under.
20+
function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: string | null }) {
21+
return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId);
22+
}
23+
24+
const routeConfig = {
25+
params: ParamsSchema,
26+
allowJWT: true,
27+
corsStrategy: "all" as const,
28+
findResource: async (params: z.infer<typeof ParamsSchema>, auth: { environment: { id: string } }) =>
29+
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId),
30+
};
31+
32+
export const { action } = createActionApiRoute(
33+
{ ...routeConfig, method: "PUT" },
34+
async ({ authentication, resource: session }) => {
35+
if (!session) {
36+
return json({ error: "Session not found" }, { status: 404 });
37+
}
38+
39+
const signed = await generatePresignedUrl(
40+
authentication.environment.project.externalRef,
41+
authentication.environment.slug,
42+
snapshotKey(session),
43+
"PUT"
44+
);
45+
if (!signed.success) {
46+
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
47+
}
48+
49+
return json({ presignedUrl: signed.url });
50+
}
51+
);
52+
53+
export const loader = createLoaderApiRoute(routeConfig, async ({ authentication, resource: session }) => {
54+
if (!session) {
55+
return json({ error: "Session not found" }, { status: 404 });
56+
}
57+
58+
const signed = await generatePresignedUrl(
59+
authentication.environment.project.externalRef,
60+
authentication.environment.slug,
61+
snapshotKey(session),
62+
"GET"
63+
);
64+
if (!signed.success) {
65+
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
66+
}
67+
68+
return json({ presignedUrl: signed.url });
69+
});

apps/webapp/app/routes/api.v1.sessions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ensureRunForSession,
1818
type SessionTriggerConfig,
1919
} from "~/services/realtime/sessionRunManager.server";
20+
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
2021
import { serializeSession } from "~/services/realtime/sessions.server";
2122
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
2223
import {
@@ -181,6 +182,7 @@ const { action } = createActionApiRoute(
181182
environmentType: authentication.environment.type,
182183
organizationId: authentication.environment.organizationId,
183184
streamBasinName: authentication.environment.organization.streamBasinName,
185+
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
184186
},
185187
update: { triggerConfig: triggerConfigJson },
186188
});
@@ -201,6 +203,7 @@ const { action } = createActionApiRoute(
201203
environmentType: authentication.environment.type,
202204
organizationId: authentication.environment.organizationId,
203205
streamBasinName: authentication.environment.organization.streamBasinName,
206+
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
204207
},
205208
});
206209
}

apps/webapp/app/services/apiRateLimit.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
7070
// customer-facing surface so customer rate limits shouldn't apply.
7171
/^\/api\/v1\/packets\//,
7272
/^\/api\/v2\/packets\//,
73+
/^\/api\/v1\/sessions\/[^\/]+\/snapshot-url$/,
7374
],
7475
log: {
7576
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { env } from "~/env.server";
2+
3+
/**
4+
* Canonical storage URI for a session's chat.agent snapshot. Stamped on
5+
* `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns
6+
* resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL`
7+
* changes later.
8+
*/
9+
export function chatSnapshotStoragePathForSession(friendlyId: string): string {
10+
const path = `sessions/${friendlyId}/snapshot.json`;
11+
const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL;
12+
return protocol ? `${protocol}://${path}` : path;
13+
}

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger
22
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
33
import { z } from "zod";
44
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
5-
import { $transaction, prisma } from "~/db.server";
5+
import { $replica, $transaction, prisma, type PrismaReplicaClient } from "~/db.server";
66
import { env } from "~/env.server";
77
import { getSecretStore } from "~/services/secrets/secretStore.server";
88
import { generateFriendlyId } from "../friendlyIdentifiers";
@@ -47,7 +47,10 @@ function parseSecretKey(key: string) {
4747
const SecretValue = z.object({ secret: z.string() });
4848

4949
export class EnvironmentVariablesRepository implements Repository {
50-
constructor(private prismaClient: PrismaClient = prisma) {}
50+
constructor(
51+
private prismaClient: PrismaClient = prisma,
52+
private replicaClient: PrismaReplicaClient = $replica
53+
) {}
5154

5255
async create(projectId: string, options: CreateEnvironmentVariables): Promise<CreateResult> {
5356
const project = await this.prismaClient.project.findFirst({
@@ -582,7 +585,7 @@ export class EnvironmentVariablesRepository implements Repository {
582585
const variables = await this.getEnvironment(projectId, environmentId, parentEnvironmentId);
583586

584587
// Get the keys of all secret variables
585-
const secretValues = await this.prismaClient.environmentVariableValue.findMany({
588+
const secretValues = await this.replicaClient.environmentVariableValue.findMany({
586589
where: {
587590
environmentId: parentEnvironmentId
588591
? { in: [environmentId, parentEnvironmentId] }

apps/webapp/test/chat-snapshot-integration.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import type { UIMessage } from "ai";
2727
import { afterEach, describe, expect, vi } from "vitest";
2828
import { env } from "~/env.server";
29+
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
2930
import { generatePresignedUrl } from "~/v3/objectStore.server";
3031

3132
vi.setConfig({ testTimeout: 60_000 });
@@ -54,22 +55,21 @@ function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string }
5455

5556
/**
5657
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
57-
* api client whose `getPayloadUrl` / `createUploadPayloadUrl` return
58-
* presigned URLs minted by the webapp's real `generatePresignedUrl`
59-
* (which signs against MinIO).
60-
*
61-
* The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write
62-
* the blob, so MinIO ends up holding the actual bytes.
58+
* api client. Mirrors the snapshot-url route: derive the canonical
59+
* `sessions/{id}/snapshot.json` key (with optional default-protocol
60+
* prefix) and sign it via `generatePresignedUrl` against MinIO.
6361
*/
6462
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
6563
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
66-
async getPayloadUrl(filename: string) {
67-
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
64+
async getChatSnapshotUrl(sessionId: string) {
65+
const key = chatSnapshotStoragePathForSession(sessionId);
66+
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
6867
if (!result.success) throw new Error(result.error);
6968
return { presignedUrl: result.url };
7069
},
71-
async createUploadPayloadUrl(filename: string) {
72-
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
70+
async createChatSnapshotUploadUrl(sessionId: string) {
71+
const key = chatSnapshotStoragePathForSession(sessionId);
72+
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
7373
if (!result.success) throw new Error(result.error);
7474
return { presignedUrl: result.url };
7575
},

apps/webapp/test/replay-after-crash.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import type { UIMessageChunk } from "ai";
3434
import { afterEach, describe, expect, vi } from "vitest";
3535
import { env } from "~/env.server";
36+
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
3637
import { generatePresignedUrl } from "~/v3/objectStore.server";
3738

3839
vi.setConfig({ testTimeout: 60_000 });
@@ -77,13 +78,15 @@ function stubApiClient(opts: {
7778
})
7879
);
7980
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
80-
async getPayloadUrl(filename: string) {
81-
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
81+
async getChatSnapshotUrl(sessionId: string) {
82+
const key = chatSnapshotStoragePathForSession(sessionId);
83+
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
8284
if (!result.success) throw new Error(result.error);
8385
return { presignedUrl: result.url };
8486
},
85-
async createUploadPayloadUrl(filename: string) {
86-
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
87+
async createChatSnapshotUploadUrl(sessionId: string) {
88+
const key = chatSnapshotStoragePathForSession(sessionId);
89+
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
8790
if (!result.success) throw new Error(result.error);
8891
return { presignedUrl: result.url };
8992
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- CreateIndex
2+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "EnvironmentVariableValue_environmentId_idx"
3+
ON "EnvironmentVariableValue"("environmentId");

0 commit comments

Comments
 (0)