Files
triggerdotdev--trigger.dev/apps/webapp/app/services/realtime/sessions.server.ts
T
Eric Allam c0b84595a3 feat(webapp): hosted webhook ingress, delivery pipeline, and dashboard (#4344)
## Summary

The server half of hosted webhooks: the public ingress endpoint,
signature verification, the delivery pipeline (Postgres partitioned
storage + ClickHouse for ordering), the in-app partition manager, the
HTTP API, and the dashboard (Deliveries, Endpoints, and the in-app test
console).

The public SDK and docs half is #4537. That PR carries the user-facing
API (`webhook()`, `chat.event` / `chat.channels`, the
`@trigger.dev/slack` connector) and builds on the shared
`@trigger.dev/core` schemas that ship here.

## Shipping behind a flag

A `WEBHOOK_ENABLED` env var (default off) gates the public ingress route
and the engine worker plus partition cron, so merging and deploying this
changes nothing in production until it is flipped on per environment.
The dashboard is separately gated per org by the `hasWebhooksAccess`
feature flag.

## Note on packages

This PR includes the `@trigger.dev/core` schema additions the server
compiles against, but carries no changeset. Core is not consumed
independently of the SDK, so it is released together with the SDK via
#4537. Keeping its changeset off `main` means no release cut from `main`
publishes it early.
2026-08-16 14:33:42 +01:00

264 lines
10 KiB
TypeScript

import type { Prisma, PrismaClient, Session } from "@trigger.dev/database";
import type { SessionItem, SessionTriggerConfig } from "@trigger.dev/core/v3";
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
import type { RunStore } from "@internal/run-store";
import { $replica, prisma } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import { boundedIn } from "@trigger.dev/database";
/**
* Prefix that {@link SessionId.generate} attaches to every Session friendlyId.
* Used to distinguish friendlyId lookups (`session_abc...`) from externalId
* lookups on the public `GET /api/v1/sessions/:session` route.
*/
const SESSION_FRIENDLY_ID_PREFIX = "session_";
/**
* Resolve a session from a URL path parameter that may contain either a
* friendlyId (`session_abc...`) or a user-supplied externalId.
*
* Disambiguated by prefix: values starting with `session_` are treated as
* friendlyIds, anything else is looked up against `externalId` scoped to
* the caller's environment.
*/
// CONTROL-PLANE: `Session` lives on the control-plane DB; these reads are NOT
// routed to run-ops read-through — only the `TaskRun` currentRunId resolves in
// this file are run-ops read-through routed.
export async function resolveSessionByIdOrExternalId(
prisma: Pick<PrismaClient, "session">,
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
if (isSessionFriendlyIdForm(idOrExternalId)) {
return prisma.session.findFirst({
where: { friendlyId: idOrExternalId, runtimeEnvironmentId },
});
}
// `findFirst` rather than `findUnique` per the repo rule — `findUnique`'s
// implicit DataLoader has open correctness bugs in Prisma 6.x that bite
// hot-path lookups exactly like this one.
return prisma.session.findFirst({
where: { runtimeEnvironmentId, externalId: idOrExternalId },
});
}
/**
* Replica-first session resolution with a writer fallback on miss. For the
* hot realtime routes (append / SSE subscribe / end-and-continue): a fresh
* session's first append or subscribe can arrive inside the replica's apply
* window, and a bare replica miss there surfaces as a 404 (or a subscribe
* that never finds its session) for a session that exists on the writer.
*/
export async function resolveSessionWithWriterFallback(
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
const row = await resolveSessionByIdOrExternalId($replica, runtimeEnvironmentId, idOrExternalId);
if (row) return row;
return resolveSessionByIdOrExternalId(prisma, runtimeEnvironmentId, idOrExternalId);
}
/** True for `session_*` friendlyId form, false for everything else. */
export function isSessionFriendlyIdForm(value: string): boolean {
return value.startsWith(SESSION_FRIENDLY_ID_PREFIX);
}
/**
* Find-or-create a Session row. Idempotent on `(environment, externalId)`: two concurrent callers
* converge to the same row (and `triggerConfig` is refreshed on the cached path so a redeployed
* config propagates to the next run). Shared by `POST /api/v1/sessions` and the webhook session
* delivery port so the create-field set (org/env scoping, streamBasin, snapshot path) stays in one place.
*/
export async function findOrCreateSession(params: {
environment: AuthenticatedEnvironment;
externalId?: string;
type: string;
taskIdentifier: string;
triggerConfig: SessionTriggerConfig;
tags?: string[];
metadata?: Record<string, unknown>;
expiresAt?: Date | null;
}): Promise<{ session: Session; isCached: boolean }> {
const { id, friendlyId } = SessionId.generate();
const env = params.environment;
const triggerConfigJson = params.triggerConfig as unknown as Prisma.InputJsonValue;
const common = {
type: params.type,
taskIdentifier: params.taskIdentifier,
triggerConfig: triggerConfigJson,
tags: params.tags ?? [],
metadata: params.metadata as Prisma.InputJsonValue | undefined,
expiresAt: params.expiresAt ?? null,
projectId: env.projectId,
runtimeEnvironmentId: env.id,
environmentType: env.type,
organizationId: env.organizationId,
streamBasinName: env.organization.streamBasinName,
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
};
if (params.externalId) {
const session = await prisma.session.upsert({
where: {
runtimeEnvironmentId_externalId: {
runtimeEnvironmentId: env.id,
externalId: params.externalId,
},
},
create: { id, friendlyId, externalId: params.externalId, ...common },
update: { triggerConfig: triggerConfigJson },
});
return { session, isCached: session.id !== id };
}
const session = await prisma.session.create({ data: { id, friendlyId, ...common } });
return { session, isCached: false };
}
/** Find a session by externalId without creating one (resume-only channel delivery, e.g. startOn). */
export async function findSessionByExternalId(
environment: AuthenticatedEnvironment,
externalId: string
): Promise<Session | null> {
return prisma.session.findUnique({
where: {
runtimeEnvironmentId_externalId: { runtimeEnvironmentId: environment.id, externalId },
},
});
}
/**
* Canonicalise the addressing key used for everything stream-level: the
* S2 stream path and the run-engine waitpoint cache key. `chat.agent`
* and the rest of the operational surface always pass `externalId`, but
* a public-API caller may legitimately address by `friendlyId` — and a
* session created without an `externalId` only has a friendlyId at all.
*
* Rule:
* - If we have a Session row, the canonical key is `externalId` if
* set, else `friendlyId`. This way two callers addressing the same
* row via different forms always converge to the same S2 stream.
* - If we have no row (yet — chat.agent's transport may subscribe
* before the agent's bind-time upsert lands), the canonical key is
* whatever the URL had. Operationally that's always an externalId.
* Friendlyid-form callers without a matching row are rejected by
* the route handler before this is reached.
*/
export function canonicalSessionAddressingKey(row: Session | null, paramSession: string): string {
if (row) {
return row.externalId ?? row.friendlyId;
}
return paramSession;
}
/**
* Convert a Prisma `Session` row to the public {@link SessionItem} wire format.
* Strips internal columns (project/environment/organization ids) and narrows
* the `metadata` JSON to a record.
*
* Note: `currentRunId` is left as-is — Prisma stores the internal run id
* (cuid), but `SessionItem.currentRunId` is the *friendly* form. Routes
* that emit `SessionItem`s must translate: single-row endpoints via
* {@link serializeSessionWithFriendlyRunId}, list endpoints via the
* batched {@link serializeSessionsWithFriendlyRunIds}. Never put this
* raw form on the wire directly.
*/
export function serializeSession(session: Session): SessionItem {
return {
id: session.friendlyId,
externalId: session.externalId,
type: session.type,
taskIdentifier: session.taskIdentifier,
triggerConfig: session.triggerConfig as SessionItem["triggerConfig"],
currentRunId: session.currentRunId,
tags: session.tags,
metadata: (session.metadata ?? null) as SessionItem["metadata"],
closedAt: session.closedAt,
closedReason: session.closedReason,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
/**
* Same as {@link serializeSession} but resolves `currentRunId` from the
* internal cuid to the public `run_*` friendlyId via a TaskRun lookup.
* Single-row endpoints (`POST/GET/PATCH/close /api/v1/sessions/:s`) use
* this so the wire-side `currentRunId` is consistent with the rest of
* the public API (which only accepts friendlyIds for run lookups).
*
* Skips the lookup when `currentRunId` is null.
*
* Resolves `currentRunId` -> `friendlyId` through `runStore.findRun` so a
* run-ops id (NEW-DB) session run resolves from its owning store rather than the
* control-plane replica. Mirrors `sessionRunManager.server.ts`.
* Tenant-scoped because `Session.currentRunId` is a no-FK pointer.
*/
export async function serializeSessionWithFriendlyRunId(
session: Session,
runStore: RunStore = defaultRunStore
): Promise<SessionItem> {
const base = serializeSession(session);
if (!session.currentRunId) return base;
const run = await runStore.findRun(
{
id: session.currentRunId,
projectId: session.projectId,
runtimeEnvironmentId: session.runtimeEnvironmentId,
},
{ select: { friendlyId: true } }
);
return {
...base,
currentRunId: run?.friendlyId ?? null,
};
}
/**
* Batched form of {@link serializeSessionWithFriendlyRunId} for list
* endpoints: one `IN` lookup per page instead of N+1. `currentRunId` on
* the wire is always the public `run_*` friendlyId — the raw
* {@link serializeSession} form leaks the internal cuid, which customers
* can't use with `runs.retrieve(...)`.
*/
export async function serializeSessionsWithFriendlyRunIds(
sessions: Session[],
scope: { projectId: string; runtimeEnvironmentId: string },
runStore: RunStore = defaultRunStore
): Promise<SessionItem[]> {
const runIds = [
...new Set(sessions.map((s) => s.currentRunId).filter((id): id is string => !!id)),
];
// `runStore.findRuns` fans out across both stores under split (NEW + LEGACY
// replica merge) and is a plain `$replica` find when split is off. Tenant-
// scoped: `Session.currentRunId` is a no-FK pointer, so a stale id must never
// resolve a run in another env.
const runs =
runIds.length > 0
? await runStore.findRuns({
where: {
id: { in: boundedIn(runIds) },
projectId: scope.projectId,
runtimeEnvironmentId: scope.runtimeEnvironmentId,
},
select: { id: true, friendlyId: true },
})
: [];
const friendlyIdByRunId = new Map(runs.map((run) => [run.id, run.friendlyId]));
return sessions.map((session) => ({
...serializeSession(session),
currentRunId: session.currentRunId
? (friendlyIdByRunId.get(session.currentRunId) ?? null)
: null,
}));
}