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

136 lines
5.1 KiB
TypeScript

import { json } from "@remix-run/server-runtime";
import {
EndAndContinueSessionRequestBody,
type EndAndContinueSessionResponseBody,
} from "@trigger.dev/core/v3";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { swapSessionRun } from "~/services/realtime/sessionRunManager.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { runStore } from "~/v3/runStore.server";
const ParamsSchema = z.object({
session: z.string(),
});
// POST /api/v1/sessions/:session/end-and-continue
//
// Generic "the running run is exiting; please trigger a fresh one for
// this session and swap `currentRunId` to it" endpoint. The agent calls
// this from `chat.requestUpgrade` and other planned-handoff paths. The
// transport's `.out` SSE keeps streaming across the swap because S2 is
// keyed on the session, not the run — v1's last chunks land, v2's new
// chunks land on the same stream.
//
// Auth: `write:sessions:{ext}` — the running agent's internal API key
// (PRIVATE) bypasses authorization; a browser holding the session PAT
// can also reach this endpoint, which is fine: if you have the session
// PAT, you own the chat.
const { action, loader } = createActionApiRoute(
{
params: ParamsSchema,
body: EndAndContinueSessionRequestBody,
method: "POST",
maxContentLength: 1024,
allowJWT: true,
corsStrategy: "all",
// Resolved before authorization so the auth scope can expand to both
// addressing forms (friendlyId + externalId). Handler reads the row
// from `resource` instead of re-fetching.
findResource: async (params, auth) =>
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session),
authorization: {
action: "write",
// Multi-key: the session is addressable by URL param, friendlyId,
// and externalId — a JWT scoped to any of them grants access.
// Type-level `write:sessions` (no id) also matches; `write:all` /
// `admin` bypass via the JWT ability's wildcard branches.
resource: (params, _, __, ___, session) => {
const ids = new Set<string>([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
},
},
async ({ authentication, params, body, resource: session }) => {
if (!session) {
// Unreachable — `findResource` 404s before this runs. Type narrow.
return json({ error: "Session not found" }, { status: 404 });
}
if (session.closedAt) {
return json({ error: "Cannot end-and-continue a closed session" }, { status: 400 });
}
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
return json({ error: "Cannot end-and-continue an expired session" }, { status: 400 });
}
// The wire `callingRunId` is a friendlyId (that's what the agent
// SDK exposes via `ctx.run.id`). Internally `Session.currentRunId`
// stores the TaskRun.id cuid, so resolve before handing to the
// optimistic-claim service.
const callingRun = await runStore.findRun(
{
friendlyId: body.callingRunId,
runtimeEnvironmentId: authentication.environment.id,
},
{ select: { id: true } },
$replica
);
if (!callingRun) {
return json({ error: "callingRunId not found in this environment" }, { status: 404 });
}
try {
// Body's `reason` is free-form for forward-compat (audit metadata
// only); narrow into the closed `EnsureRunReason` set, defaulting
// to `"manual"` for unknown labels.
const reason: "initial" | "continuation" | "upgrade" | "manual" =
body.reason === "upgrade" ||
body.reason === "continuation" ||
body.reason === "initial" ||
body.reason === "manual"
? body.reason
: "manual";
const result = await swapSessionRun({
session,
callingRunId: callingRun.id,
environment: authentication.environment,
reason,
});
// Read-after-write: the swap just triggered (or claimed) the
// run on the writer, so read it from `prisma` rather than
// `$replica`. A replica miss here would silently fall back to
// returning the internal cuid, which the public API contract
// says is a friendlyId.
const run = await runStore.findRun(
{ id: result.runId },
{ select: { friendlyId: true } },
prisma
);
const responseBody: EndAndContinueSessionResponseBody = {
runId: run?.friendlyId ?? result.runId,
swapped: result.swapped,
};
return json<EndAndContinueSessionResponseBody>(responseBody);
} catch (error) {
logger.error("Failed end-and-continue", {
sessionId: session.id,
error,
});
return json({ error: "Failed to swap session run" }, { status: 500 });
}
}
);
export { action, loader };