309 lines
13 KiB
TypeScript
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 };
|