Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.action.tsx
2026-07-13 13:32:57 +08:00

317 lines
11 KiB
TypeScript

import { json } from "@remix-run/server-runtime";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import type { Prisma } from "@trigger.dev/database";
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
import { prisma } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { mintSessionToken } from "~/services/realtime/mintSessionToken.server";
import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server";
import { runStore } from "~/v3/runStore.server";
const PlaygroundAction = z.object({
intent: z.enum(["create", "start", "save", "delete"]),
agentSlug: z.string(),
// For create
conversationId: z.string().optional(),
// For start (replaces "trigger" — atomically creates the Session and
// triggers its first run, returns a session-scoped PAT)
chatId: z.string().optional(),
payload: z.string().optional(),
clientData: z.string().optional(),
tags: z.string().optional(),
machine: z.string().optional(),
maxAttempts: z.string().optional(),
maxDuration: z.string().optional(),
version: z.string().optional(),
region: z.string().optional(),
// For save
messages: z.string().optional(),
lastEventId: z.string().optional(),
// For delete
deleteConversationId: z.string().optional(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
}
const { intent } = parsed.data;
switch (intent) {
case "create": {
const { agentSlug } = parsed.data;
const chatId = crypto.randomUUID();
const conversation = await prisma.playgroundConversation.create({
data: {
chatId,
agentSlug,
projectId: project.id,
runtimeEnvironmentId: environment.id,
userId,
},
});
return json({
conversationId: conversation.id,
chatId,
});
}
case "start": {
const {
agentSlug,
chatId,
payload: payloadStr,
clientData,
tags: tagsStr,
machine,
maxAttempts,
maxDuration,
version,
region,
} = parsed.data;
if (!chatId) {
return json({ error: "chatId is required" }, { status: 400 });
}
// Parse the optional initial payload — used as the basePayload
// for the first run trigger. After session create, the agent
// reads subsequent messages from `.in/append` so the payload
// here is just the bootstrap.
let payload: Record<string, any> = {};
try {
payload = payloadStr ? (JSON.parse(payloadStr) as Record<string, any>) : {};
} catch {
return json({ error: "Invalid payload JSON" }, { status: 400 });
}
let parsedClientData: unknown;
try {
parsedClientData = clientData ? JSON.parse(clientData) : undefined;
} catch {
/* invalid JSON — fall through with undefined */
}
const tags = [
`chat:${chatId}`,
"playground:true",
...(tagsStr
? tagsStr
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: []),
].slice(0, 5);
const triggerConfig = {
basePayload: {
// The first run boots before the user's first message lands on
// `.in/append`, so it sees `messages: []` and `trigger: "preload"`.
// Mirrors the defaults in `chat.createStartSessionAction` —
// chat.agent's runtime reads `payload.messages.length` so the
// field must be an array, not undefined.
messages: [],
trigger: "preload",
...payload,
chatId,
...(parsedClientData ? { metadata: parsedClientData } : {}),
},
...(machine ? { machine } : {}),
tags,
...(maxAttempts ? { maxAttempts: parseInt(maxAttempts, 10) } : {}),
...(maxDuration ? { maxDuration: parseInt(maxDuration, 10) } : {}),
...(version ? { lockToVersion: version } : {}),
...(region ? { region } : {}),
};
// Atomic: upsert the Session, then trigger the first run via
// the optimistic-claim path. The transport's `accessToken`
// callback hits this endpoint on initial start AND on 401 — the
// upsert + ensureRunForSession combo is idempotent so repeat
// calls converge to the same session and (if alive) reuse the
// existing run.
const { id: sessionId, friendlyId } = SessionId.generate();
const session = await prisma.session.upsert({
where: {
runtimeEnvironmentId_externalId: {
runtimeEnvironmentId: environment.id,
externalId: chatId,
},
},
create: {
id: sessionId,
friendlyId,
externalId: chatId,
type: "chat.agent",
taskIdentifier: agentSlug,
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
// Mark as a Test session — surfaced via the Test column in the
// Sessions table. Session tags stay empty; the triggered run still
// carries "playground:true" via triggerConfig.tags.
isTest: true,
projectId: project.id,
runtimeEnvironmentId: environment.id,
environmentType: environment.type,
organizationId: project.organizationId,
// Stamp the org's S2 basin so realtime reads on this
// session's `.in/.out` channels resolve without joining
// Organization. Null until per-org basins are provisioned.
streamBasinName: environment.organization.streamBasinName,
},
update: {
// Refresh trigger config in case agent version / params changed
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
},
});
const ensureResult = await ensureRunForSession({
session,
environment,
reason: "initial",
});
const run = await runStore.findRun(
{ id: ensureResult.runId },
{ select: { friendlyId: true } },
prisma
);
if (!run) {
return json({ error: "Triggered run not found" }, { status: 500 });
}
// Title: prefer the user message text on first start, else a
// generic placeholder. The conversation row is the playground's
// own surface — separate from the Session row that drives the
// trigger.
const firstMessage = payload?.messages?.[0];
const firstText =
firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
const conversation = await prisma.playgroundConversation.upsert({
where: {
chatId_runtimeEnvironmentId: {
chatId,
runtimeEnvironmentId: environment.id,
},
},
create: {
chatId,
title,
agentSlug,
runId: ensureResult.runId,
clientData: parsedClientData as any,
projectId: project.id,
runtimeEnvironmentId: environment.id,
userId,
},
update: {
runId: ensureResult.runId,
clientData: parsedClientData as any,
title,
},
});
const publicAccessToken = await mintSessionToken(environment, chatId);
return json({
runId: run.friendlyId,
publicAccessToken,
conversationId: conversation.id,
});
}
case "save": {
const { chatId, messages: messagesStr, lastEventId } = parsed.data;
if (!chatId) {
return json({ error: "chatId is required" }, { status: 400 });
}
let messagesData: unknown;
try {
messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
} catch {
return json({ error: "Invalid messages JSON" }, { status: 400 });
}
// Extract title from the first user message if the conversation still has the default title.
// This handles the case where a preloaded conversation gets its first real message
// via the input stream (bypassing the trigger action that normally sets the title).
let titleUpdate: { title: string } | undefined;
if (messagesData && Array.isArray(messagesData)) {
const existing = await prisma.playgroundConversation.findFirst({
where: { chatId, runtimeEnvironmentId: environment.id, userId },
select: { title: true },
});
if (existing?.title === "New conversation") {
const firstUserMsg = messagesData.find((m: any) => m.role === "user") as
| Record<string, any>
| undefined;
const firstText =
firstUserMsg?.parts?.find((p: any) => p.type === "text")?.text ?? firstUserMsg?.content;
if (firstText && typeof firstText === "string") {
titleUpdate = {
title: firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText,
};
}
}
}
await prisma.playgroundConversation.updateMany({
where: {
chatId,
runtimeEnvironmentId: environment.id,
userId,
},
data: {
...(messagesData ? { messages: messagesData as any } : {}),
...(lastEventId ? { lastEventId } : {}),
...titleUpdate,
},
});
return json({ ok: true });
}
case "delete": {
const { deleteConversationId } = parsed.data;
if (!deleteConversationId) {
return json({ error: "deleteConversationId is required" }, { status: 400 });
}
await prisma.playgroundConversation.deleteMany({
where: {
id: deleteConversationId,
runtimeEnvironmentId: environment.id,
userId,
},
});
return json({ ok: true });
}
}
};