136 lines
5.1 KiB
TypeScript
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 };
|