Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/api.v1.sessions.ts
T
2026-07-13 13:32:57 +08:00

309 lines
13 KiB
TypeScript

import { json } from "@remix-run/server-runtime";
import {
CreateSessionRequestBody,
type CreatedSessionResponseBody,
ListSessionsQueryParams,
type ListSessionsResponseBody,
type SessionItem,
type SessionStatus,
} from "@trigger.dev/core/v3";
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
import type { Prisma, Session } from "@trigger.dev/database";
import { $replica, prisma, type PrismaClient } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { logger } from "~/services/logger.server";
import { mintSessionToken } from "~/services/realtime/mintSessionToken.server";
import {
ensureRunForSession,
type SessionTriggerConfig,
} from "~/services/realtime/sessionRunManager.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import {
serializeSession,
serializeSessionsWithFriendlyRunIds,
} from "~/services/realtime/sessions.server";
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
import {
anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/common.server";
import { runStore } from "~/v3/runStore.server";
function asArray<T>(value: T | T[] | undefined): T[] | undefined {
if (value === undefined) return undefined;
return Array.isArray(value) ? value : [value];
}
export const loader = createLoaderApiRoute(
{
searchParams: ListSessionsQueryParams,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
// Multi-key resource preserves the pre-RBAC superScope semantics:
// - Per-task scoping via `read:tasks:<id>` matches a task element
// - Type-level `read:sessions` (the old superScope) matches the
// sessions element (collection-level — no id)
// - `read:all` / `admin` bypass via the JWT ability's wildcard branches
// The taskIdentifier filter accepts a string or an array; expand to
// one resource per task id so any per-task-scoped JWT among them
// grants access (the array gets OR semantics).
resource: (_, __, searchParams) => {
const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? [];
return anyResource([
...taskFilter.map((id) => ({ type: "tasks" as const, id })),
{ type: "sessions" as const },
]);
},
},
findResource: async () => 1,
},
async ({ searchParams, authentication }) => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
authentication.environment.organizationId,
"standard"
);
const repository = new SessionsRepository({
clickhouse,
prisma: $replica as PrismaClient,
});
// `page[after]` is the forward cursor, `page[before]` is the backward
// cursor. The repository internally keys off `{cursor, direction}`.
const cursor = searchParams["page[after]"] ?? searchParams["page[before]"];
const direction = searchParams["page[before]"] ? "backward" : "forward";
const { sessions: rows, pagination } = await repository.listSessions({
organizationId: authentication.environment.organizationId,
projectId: authentication.environment.projectId,
environmentId: authentication.environment.id,
types: asArray(searchParams["filter[type]"]),
tags: asArray(searchParams["filter[tags]"]),
taskIdentifiers: asArray(searchParams["filter[taskIdentifier]"]),
externalId: searchParams["filter[externalId]"],
statuses: asArray(searchParams["filter[status]"]) as SessionStatus[] | undefined,
period: searchParams["filter[createdAt][period]"],
from: searchParams["filter[createdAt][from]"],
to: searchParams["filter[createdAt][to]"],
page: {
size: searchParams["page[size]"],
cursor,
direction,
},
});
// Batched friendlyId translation: `currentRunId` on the wire is the
// public `run_*` form, matching the single-session routes. One `IN`
// lookup per page.
const data = await serializeSessionsWithFriendlyRunIds(
rows.map(
(row) =>
({
...row,
// Columns the list query doesn't select — filled so the
// serializer can operate on a narrowed payload without type errors.
projectId: authentication.environment.projectId,
environmentType: authentication.environment.type,
organizationId: authentication.environment.organizationId,
}) as Session
),
{
projectId: authentication.environment.projectId,
runtimeEnvironmentId: authentication.environment.id,
}
);
return json<ListSessionsResponseBody>({
data,
pagination: {
...(pagination.nextCursor ? { next: pagination.nextCursor } : {}),
...(pagination.previousCursor ? { previous: pagination.previousCursor } : {}),
},
});
}
);
const { action } = createActionApiRoute(
{
body: CreateSessionRequestBody,
method: "POST",
maxContentLength: 1024 * 32, // 32KB — metadata is the only thing that grows
// Customer's server (typically wrapping
// `chat.createStartSessionAction`) owns session creation so any
// authorization decision (per-user/plan/quota) sits server-side
// alongside whatever DB write the customer pairs with the create.
// The session-scoped PAT returned in the response body is what the
// browser uses thereafter against `.in/append`, `.out` SSE,
// `end-and-continue`, etc.
//
// JWT is allowed when the caller holds an explicit `write:sessions` /
// `admin` super-scope plus a `tasks:<taskIdentifier>` scope — gates
// server-side surfaces like the cli-v3 MCP from creating sessions on
// behalf of the developer without weakening the browser model.
allowJWT: true,
authorization: {
// Per-task scoping via `body.taskIdentifier` (action-route resource
// callbacks receive the parsed body as the 4th arg — see
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
// can only create sessions whose `taskIdentifier` is `"foo"`.
//
// Multi-key resource: pre-RBAC this route had a `superScopes:
// ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
// is the `{ type: "sessions" }` element below — a `write:sessions`
// JWT (no id) matches it directly, deliberately bypassing the
// per-task check exactly as before. `admin` / `write:all` bypass
// via the JWT ability's wildcard branches.
action: "write",
resource: (_params, _searchParams, _headers, body) =>
anyResource([{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }]),
},
corsStrategy: "all",
},
async ({ authentication, body }) => {
try {
const { id, friendlyId } = SessionId.generate();
// Idempotent on (env, externalId): two concurrent POSTs converge
// to the same row. We refresh `triggerConfig` on the cached path
// so newly-deployed schema changes (e.g. an updated
// `clientDataSchema` on the agent) propagate to subsequent runs
// — the next `ensureRunForSession` reads back the latest config.
let session: Session;
let isCached = false;
const triggerConfigJson = body.triggerConfig as unknown as Prisma.InputJsonValue;
if (body.externalId) {
session = await prisma.session.upsert({
where: {
runtimeEnvironmentId_externalId: {
runtimeEnvironmentId: authentication.environment.id,
externalId: body.externalId,
},
},
create: {
id,
friendlyId,
externalId: body.externalId,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: triggerConfigJson,
tags: body.tags ?? [],
metadata: body.metadata as Prisma.InputJsonValue | undefined,
expiresAt: body.expiresAt ?? null,
projectId: authentication.environment.projectId,
runtimeEnvironmentId: authentication.environment.id,
environmentType: authentication.environment.type,
organizationId: authentication.environment.organizationId,
streamBasinName: authentication.environment.organization.streamBasinName,
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
},
update: { triggerConfig: triggerConfigJson },
});
isCached = session.id !== id;
} else {
session = await prisma.session.create({
data: {
id,
friendlyId,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: triggerConfigJson,
tags: body.tags ?? [],
metadata: body.metadata as Prisma.InputJsonValue | undefined,
expiresAt: body.expiresAt ?? null,
projectId: authentication.environment.projectId,
runtimeEnvironmentId: authentication.environment.id,
environmentType: authentication.environment.type,
organizationId: authentication.environment.organizationId,
streamBasinName: authentication.environment.organization.streamBasinName,
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
},
});
}
// Reject create on a closed session. The upsert path will return
// an already-closed row when the caller reuses an externalId, and
// without this guard `ensureRunForSession` would trigger a fresh
// run that can't receive `.in` input (the append handler 409s on
// closed sessions). Force the caller to use a different externalId
// — `close` is one-way.
if (session.closedAt) {
return json(
{ error: "Session is closed; use a different externalId to create a new session" },
{ status: 409 }
);
}
// Same guard as the append / end-and-continue handlers: an expired
// row must not spawn a run, because every subsequent `.in/append`
// would 400 on the expiry check — a run boots but the chat can
// never receive input.
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
return json(
{ error: "Session is expired; use a different externalId to create a new session" },
{ status: 409 }
);
}
// Session is task-bound — every session has a live run by
// construction. `ensureRunForSession` is idempotent: on the
// cached path it sees `currentRunId` is alive and returns it
// without re-triggering.
const ensureResult = await ensureRunForSession({
session,
environment: authentication.environment,
reason: isCached ? "continuation" : "initial",
});
// Read-after-write: the run was just triggered in this request,
// so go to the writer rather than $replica. Replica lag here
// would null this out and turn a successful create into a 500.
const run = await runStore.findRun(
{ id: ensureResult.runId },
{ select: { friendlyId: true } },
prisma
);
if (!run) {
throw new Error(`Triggered run ${ensureResult.runId} not found`);
}
// Mint a session-scoped PAT keyed on the addressing string the
// transport will use everywhere (`.in/append`, `.out` SSE,
// `end-and-continue`). For sessions with an externalId, that's
// the externalId; otherwise the friendlyId. Mirrors the
// canonical addressing key used server-side.
const addressingKey = session.externalId ?? session.friendlyId;
const publicAccessToken = await mintSessionToken(authentication.environment, addressingKey);
const sessionItem: SessionItem = {
...serializeSession(session),
triggerConfig: session.triggerConfig as unknown as SessionTriggerConfig,
currentRunId: run.friendlyId,
};
const responseBody: CreatedSessionResponseBody = {
...sessionItem,
runId: run.friendlyId,
publicAccessToken,
isCached,
};
return json<CreatedSessionResponseBody>(responseBody, {
status: isCached ? 200 : 201,
});
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
}
logger.error("Failed to create session", { error });
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
export { action };