Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/react-router-route-matching-perf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Speed up the dashboard and API under high request load by memoizing react-router's per-request route matching, which previously re-flattened, re-ranked, and recompiled the entire route table on every request.
129 changes: 101 additions & 28 deletions apps/webapp/app/models/vercelIntegration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,26 @@ export type VercelEnvironmentVariableValue = {
isSecret: boolean;
};

/** Minimal shape of a shared (team-level) env var record from `GET /v1/env`. */
const RawSharedEnvVarSchema = z
.object({
id: z.string().optional(),
key: z.string().optional(),
type: z.string().optional(),
target: z.union([z.array(z.string()), z.string()]).optional(),
value: z.string().optional(),
applyToAllCustomEnvironments: z.boolean().optional(),
})
.passthrough();

type RawSharedEnvVar = z.infer<typeof RawSharedEnvVarSchema>;

/** Page shape of `GET /v1/env` (shared env vars), validated at the boundary. */
const SharedEnvPageSchema = z.object({
data: z.array(RawSharedEnvVarSchema).default([]),
pagination: z.object({ next: z.number().nullish() }).nullish(),
});

/** Narrowed Vercel project type – only id and name. */
export type VercelProject = Pick<ResponseBodyProjects, "id" | "name">;

Expand Down Expand Up @@ -298,6 +318,17 @@ export class VercelIntegrationRepository {
static getVercelClient(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<Vercel, VercelApiError> {
return this.getVercelClientAndToken(integration).map(({ client }) => client);
}

/**
* Resolve both the Vercel SDK client and the raw bearer token. The raw token
* is needed to paginate shared env vars via `fetch`, since the SDK's
* `listSharedEnvVariable` exposes no `until` cursor param.
*/
static getVercelClientAndToken(
integration: OrganizationIntegration & { tokenReference: SecretReference }
): ResultAsync<{ client: Vercel; accessToken: string }, VercelApiError> {
return ResultAsync.fromPromise(
(async () => {
const secretStore = getSecretStore(integration.tokenReference.provider);
Expand All @@ -308,7 +339,7 @@ export class VercelIntegrationRepository {
if (!secret) {
throw new Error("Failed to get Vercel access token");
}
return new Vercel({ bearerToken: secret.accessToken });
return { client: new Vercel({ bearerToken: secret.accessToken }), accessToken: secret.accessToken };
})(),
(error) => toVercelApiError(error)
);
Expand Down Expand Up @@ -558,8 +589,68 @@ export class VercelIntegrationRepository {
};
}

/**
* Fetch ALL shared (team-level) env var records, following pagination.
*
* Unlike the project env endpoint, the shared endpoint (`/v1/env`) DOES
* paginate (≈25/page) and the SDK's `listSharedEnvVariable` exposes no cursor
* param — so we walk pages via a raw fetch using `pagination.next` (a
* millisecond-timestamp cursor) until it is null. Shared vars are an edge
* case, so we load every page up front and return the full set.
*/
static #fetchAllSharedEnvsRaw(params: {
accessToken: string;
teamId: string;
projectId?: string;
}): ResultAsync<RawSharedEnvVar[], VercelApiError> {
const { accessToken, teamId, projectId } = params;
return ResultAsync.fromPromise(
(async () => {
const all: RawSharedEnvVar[] = [];
let until: number | undefined = undefined;
const MAX_PAGES = 200; // safety cap (1000-var ceiling / ~25 per page)

for (let page = 0; page < MAX_PAGES; page++) {
const url = new URL("https://api.vercel.com/v1/env");
url.searchParams.set("teamId", teamId);
if (projectId) url.searchParams.set("projectId", projectId);
if (until !== undefined) url.searchParams.set("until", String(until));

const response = await fetch(url.toString(), {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
const body = await response.text().catch(() => "");
const error = new Error(
`Failed to fetch Vercel shared environment variables: ${response.status} ${response.statusText} — ${body}`
) as Error & { status?: number };
error.status = response.status;
throw error;
}

const json = SharedEnvPageSchema.parse(await response.json());
all.push(...json.data);

// `next` is a millisecond-timestamp cursor; treat 0/null/undefined as "done".
const next = json.pagination?.next;
if (!next) break;
until = next;

if (page === MAX_PAGES - 1) {
logger.warn("Vercel shared env var pagination hit max page cap", { teamId, projectId });
}
}

return all;
})(),
(error) => toVercelApiError(error)
);
}

static getVercelSharedEnvironmentVariables(
client: Vercel,
accessToken: string,
teamId: string,
projectId?: string // Optional: filter by project
): ResultAsync<Array<{
Expand All @@ -569,19 +660,9 @@ export class VercelIntegrationRepository {
isSecret: boolean;
target: string[];
}>, VercelApiError> {
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variables",
{ teamId, projectId },
toVercelApiError
).map((response) => {
const envVars = response.data || [];
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).map((envVars) => {
return envVars
.filter((env): env is typeof env & { id: string; key: string } =>
.filter((env): env is RawSharedEnvVar & { id: string; key: string } =>
typeof env.id === "string" && typeof env.key === "string"
)
.map((env) => {
Expand All @@ -599,6 +680,7 @@ export class VercelIntegrationRepository {

static getVercelSharedEnvironmentVariableValues(
client: Vercel,
accessToken: string,
teamId: string,
projectId?: string // Optional: filter by project
): ResultAsync<
Expand All @@ -612,17 +694,7 @@ export class VercelIntegrationRepository {
}>,
VercelApiError
> {
return wrapVercelCallWithRecovery(
client.environment.listSharedEnvVariable({
teamId,
...(projectId && { projectId }),
}),
VercelSchemas.listSharedEnvVariable,
"Failed to fetch Vercel shared environment variable values",
{ teamId, projectId },
toVercelApiError
).andThen((listResponse) => {
const envVars = listResponse.data || [];
return this.#fetchAllSharedEnvsRaw({ accessToken, teamId, projectId }).andThen((envVars) => {
if (envVars.length === 0) {
return okAsync([]);
}
Expand All @@ -641,8 +713,8 @@ export class VercelIntegrationRepository {

if (isSecret) return null;

const listValue = (env as any).value as string | undefined;
const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined;
const listValue = env.value;
const applyToAllCustomEnvs = env.applyToAllCustomEnvironments;

if (listValue) {
return {
Expand Down Expand Up @@ -1201,7 +1273,7 @@ export class VercelIntegrationRepository {
syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping),
});

return this.getVercelClient(params.orgIntegration).andThen((client) =>
return this.getVercelClientAndToken(params.orgIntegration).andThen(({ client, accessToken }) =>
ResultAsync.fromPromise(
(async () => {
const errors: string[] = [];
Expand Down Expand Up @@ -1267,6 +1339,7 @@ export class VercelIntegrationRepository {
if (params.teamId) {
const sharedResult = await this.getVercelSharedEnvironmentVariableValues(
client,
accessToken,
params.teamId,
params.vercelProjectId
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export class VercelSettingsPresenter extends BasePresenter {
};
}

const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
const clientResult = await VercelIntegrationRepository.getVercelClientAndToken(orgIntegration);
if (clientResult.isErr()) {
return {
customEnvironments: [],
Expand All @@ -473,7 +473,7 @@ export class VercelSettingsPresenter extends BasePresenter {
isOnboardingComplete: false,
};
}
const client = clientResult.value;
const { client, accessToken } = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);

const projectIntegration = await (this._replica as PrismaClient).organizationProjectIntegration.findFirst({
Expand Down Expand Up @@ -531,7 +531,7 @@ export class VercelSettingsPresenter extends BasePresenter {
// Only fetch shared env vars if teamId is available
teamId
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
client,
accessToken,
teamId,
projectIntegration.externalEntityId
)
Expand Down
1 change: 1 addition & 0 deletions docs/ai-chat/chat-local.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,4 @@ onTurnComplete: async ({ chatId }) => {
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — `onBoot` is the canonical init site for `chat.local`.
- [Database persistence pattern](/ai-chat/patterns/database-persistence) — full per-hook breakdown using `chat.local` alongside DB rows.
- [Code execution sandbox pattern](/ai-chat/patterns/code-sandbox) — example of using `chat.local` to hold a sandbox handle across turns.
- [Database connections](/database-connections) — why the database client and its connection pool belong at module scope, not in `chat.local`.
2 changes: 1 addition & 1 deletion docs/ai-chat/lifecycle-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Standard [task lifecycle hooks](/tasks/overview) such as `onWait`, `onResume`, `

Fires **once per worker process picking up the chat** — for the initial run, for preloaded runs, AND for reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry). Does NOT fire when the same run resumes from snapshot via the idle-window suspend/resume path — use [`onChatResume`](#onchatsuspend--onchatresume) for that.

This is the right place to initialize anything that lives in the JS process for the lifetime of the run: [`chat.local`](/ai-chat/chat-local) state, DB connections, sandboxes, in-memory caches. It runs before `onPreload`, `onChatStart`, the continuation-wait branch, and any turn — so anything you set up here is available everywhere downstream.
This is the right place to initialize anything that lives in the JS process for the lifetime of the run: [`chat.local`](/ai-chat/chat-local) state, [DB connections](/database-connections), sandboxes, in-memory caches. It runs before `onPreload`, `onChatStart`, the continuation-wait branch, and any turn — so anything you set up here is available everywhere downstream.

<Warning>
If you initialize `chat.local` only in `onChatStart`, your `run()` will crash on continuation runs with `chat.local can only be modified after initialization`. `onChatStart` is once-per-chat by contract; `chat.local` is per-process and needs `onBoot`.
Expand Down
3 changes: 3 additions & 0 deletions docs/ai-chat/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,7 @@ Three primitives, related but distinct:
<Card title="Patterns" icon="puzzle-piece" href="/ai-chat/patterns/sub-agents">
HITL approvals, branching, sub-agents, OOM/crash recovery.
</Card>
<Card title="Database connections" icon="database" href="/database-connections">
Size and release connection pools so agents don't exhaust your database.
</Card>
</CardGroup>
2 changes: 1 addition & 1 deletion docs/ai-chat/patterns/database-persistence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Storing the current **`runId`** is optional — useful for telemetry / dashboard

## Where each hook writes

This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/ai-chat/chat-local), DB connection pools, sandboxes, etc.) belongs in [`onBoot`](/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.
This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/ai-chat/chat-local), [DB connection pools](/database-connections), sandboxes, etc.) belongs in [`onBoot`](/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.

### `onPreload` (optional)

Expand Down
Loading
Loading