BangSo/packages/db/src/events.ts

1178 lines
36 KiB
TypeScript
Raw Normal View History

2026-09-01 16:51:19 +00:00
import type { RealtimeFanout } from "@rakazo/adapter-kit";
import {
type MessageBlock,
MessageBlock as MessageBlockSchema,
type ProductEvent,
} from "@rakazo/contracts";
import {
blocksToAgentHistoryText,
isApprovalAskBlock,
isSecretAskBlock,
sanitizeJsonValue,
} from "@rakazo/core";
import type { Prisma, PrismaClient } from "./client.js";
import {
assertRunCanWriteHistory,
createThreadMessageInTransaction,
RunHistoryWriteError,
} from "./messages.js";
import { withTransactionRetry } from "./transaction-retry.js";
const EVENT_BATCH_SIZE = 200;
const PUSH_CATCH_UP_MS = 30_000;
const POLL_ONLY_CATCH_UP_MS = 400;
export interface AppendEventInput {
spaceId: string;
threadId: string;
botId: string;
type: ProductEvent["type"];
payload: Record<string, unknown>;
runId?: string;
}
export interface ThreadEvents {
answerRunInput(input: AnswerRunInput): Promise<boolean>;
append(input: AppendEventInput): Promise<ProductEvent>;
claimSteering(input: ClaimSteeringInput): Promise<ClaimedSteeringMessage[]>;
clearThread(input: ClearThreadInput): Promise<ClearThreadResult>;
finalizeComputerControlRelease(
input: FinalizeComputerControlReleaseInput,
): Promise<FinalizeComputerControlReleaseResult | false>;
finalizeRun(input: FinalizeRunInput): Promise<FinalizeRunResult | false>;
notify(threadId: string, seq: number): Promise<void>;
pauseRunForInput(input: PauseRunForInput): Promise<boolean>;
pauseRunForTakeover(input: PauseRunForTakeover): Promise<boolean>;
sendUserMessage(input: SendUserMessageInput): Promise<SendUserMessageResult>;
follow(threadId: string, cursor: number, signal?: AbortSignal): AsyncGenerator<ProductEvent>;
}
export interface ClearThreadInput {
spaceId: string;
threadId: string;
/** Event author and the bot-scoped target for one-to-one chats. */
botId: string;
groupId?: string;
}
export interface ClearThreadResult {
event: ProductEvent;
cancelledRunIds: string[];
historyCompactionGeneration: number;
}
export interface FinalizeComputerControlReleaseInput {
spaceId: string;
computerId: string;
botId: string;
runId: string | null;
leaseId: string;
holder: "bot" | "none";
reason: "done" | "expired" | "released" | "skipped";
}
export interface FinalizeComputerControlReleaseResult {
runId: string | null;
}
export interface ClaimSteeringInput {
threadId: string;
botId: string;
runId: string;
leaseOwner: string;
leaseFence: number;
seenIds: string[];
}
export interface ClaimedSteeringMessage {
id: string;
messageId: string;
text: string;
blocks: MessageBlock[];
}
export interface FinalizeRunResult {
continuationRunId: string | null;
}
interface FinalizeRunBase {
spaceId: string;
threadId: string;
botId: string;
runId: string;
taskId: string;
attemptId: string;
leaseOwner: string;
leaseFence: number;
}
export type FinalizeRunInput = FinalizeRunBase &
(
| { outcome: "completed"; blocks: MessageBlock[]; markUnread?: boolean }
| { outcome: "failed"; error: string }
);
export interface PauseRunForInput {
spaceId: string;
threadId: string;
botId: string;
runId: string;
attemptId: string;
leaseOwner: string;
leaseFence: number;
blocks: MessageBlock[];
/**
* Unredacted choice actions for resume. Persisted only on the run checkpoint
* (not in message blocks) so clients still see redacted labels.
*/
offeredActions?: Array<{ id: string; label: string }>;
}
const CHOICE_ASK_CHECKPOINT_KIND = "choice_ask_v1";
function choiceAskCheckpoint(actions: Array<{ id: string; label: string }>): string {
return JSON.stringify({ kind: CHOICE_ASK_CHECKPOINT_KIND, actions });
}
function resumeChoiceLabel(
selected: { id: string; label: string },
checkpoint: string | null | undefined,
): string {
if (!checkpoint) return selected.label;
try {
const parsed = JSON.parse(checkpoint) as {
kind?: string;
actions?: Array<{ id?: unknown; label?: unknown }>;
};
if (parsed.kind !== CHOICE_ASK_CHECKPOINT_KIND || !Array.isArray(parsed.actions)) {
return selected.label;
}
const offered = parsed.actions.find((action) => action.id === selected.id);
return typeof offered?.label === "string" && offered.label.length > 0
? offered.label
: selected.label;
} catch {
return selected.label;
}
}
export interface PauseRunForTakeover {
spaceId: string;
threadId: string;
botId: string;
runId: string;
attemptId: string;
leaseOwner: string;
leaseFence: number;
reason: string;
}
export interface AnswerRunInput {
spaceId: string;
threadId: string;
runId: string;
messageId: string;
answeredByUserId: string;
answer: string;
}
export interface SendUserMessageInput {
spaceId: string;
threadId: string;
botId: string;
userId: string;
blocks: MessageBlock[];
prompt: string;
trigger: "user" | "follow_up" | "webhook" | "messaging";
clientNonce?: string;
linkMessageToRun?: boolean;
}
export interface SendUserMessageResult {
messageId: string;
seq: number;
taskId: string | null;
runId: string | null;
}
export interface RunSecretWriter {
store(input: {
runId: string;
userId: string;
spaceId: string;
plaintext: string;
tx: Prisma.TransactionClient;
}): Promise<void>;
}
export function createThreadEvents(
prisma: PrismaClient,
realtime?: RealtimeFanout,
options: { catchUpMs?: number; runSecretWriter?: RunSecretWriter } = {},
): ThreadEvents {
return {
answerRunInput: (input) => answerRunInput(prisma, input, realtime, options.runSecretWriter),
append: (input) => appendEvent(prisma, input, realtime),
claimSteering: (input) => claimSteering(prisma, input),
clearThread: (input) => clearThread(prisma, input, realtime),
finalizeComputerControlRelease: (input) =>
finalizeComputerControlRelease(prisma, input, realtime),
finalizeRun: (input) => finalizeRun(prisma, input, realtime),
notify: (threadId, seq) => notifyRealtime(realtime, threadId, seq),
pauseRunForInput: (input) => pauseRunForInput(prisma, input, realtime),
pauseRunForTakeover: (input) => pauseRunForTakeover(prisma, input, realtime),
sendUserMessage: (input) => sendUserMessage(prisma, input, realtime),
follow: (threadId, cursor, signal) =>
followThreadEvents(prisma, threadId, cursor, realtime, signal, options.catchUpMs),
};
}
export async function clearThread(
prisma: PrismaClient,
input: ClearThreadInput,
realtime?: RealtimeFanout,
): Promise<ClearThreadResult> {
const committed = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const thread = await tx.thread.update({
where: {
id: input.threadId,
spaceId: input.spaceId,
...(input.groupId ? { groupId: input.groupId } : { botId: input.botId }),
},
data: { unread: false },
select: { nextMessageSeq: true, historyCompactionGeneration: true },
});
const activeRuns = await tx.run.findMany({
where: {
spaceId: input.spaceId,
threadId: input.threadId,
...(input.groupId ? {} : { botId: input.botId }),
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
},
select: { id: true, taskId: true },
});
const now = new Date();
const runIds = activeRuns.map((run) => run.id);
const taskIds = activeRuns.map((run) => run.taskId);
if (runIds.length > 0) {
await tx.run.updateMany({
where: { id: { in: runIds } },
data: {
status: "cancelled",
completedAt: now,
leaseOwner: null,
leaseExpiresAt: null,
},
});
await tx.attempt.updateMany({
where: { runId: { in: runIds }, status: "running" },
data: { status: "cancelled", finishedAt: now },
});
await tx.task.updateMany({
where: { id: { in: taskIds } },
data: { status: "cancelled" },
});
}
await tx.computerExecutionLease.deleteMany({
where: { runId: { in: runIds } },
});
await tx.computer.updateMany({
where: { executionRunId: { in: runIds } },
data: {
executionRunId: null,
executionBotId: null,
executionLeaseExpiresAt: null,
},
});
await tx.message.deleteMany({ where: { threadId: input.threadId } });
await tx.event.deleteMany({ where: { threadId: input.threadId } });
if (thread.nextMessageSeq > 0) {
// nextMessageSeq is not reset, so mark every deleted message as already compacted.
// Leaving the cursor behind would let compaction re-summarize deleted history (or, reset
// to null, immediately re-fire on the fresh conversation).
await tx.thread.update({
where: { id: input.threadId },
data: {
historyCompactedUpToSeq: thread.nextMessageSeq - 1,
historyCompactionSummary: null,
historyCompactionGeneration: { increment: 1 },
},
});
} else {
await tx.thread.update({
where: { id: input.threadId },
data: {
historyCompactionSummary: null,
historyCompactionGeneration: { increment: 1 },
},
});
}
if (input.groupId) {
await tx.chatGroup.update({ where: { id: input.groupId }, data: { updatedAt: now } });
} else {
await tx.bot.update({
where: { id: input.botId, spaceId: input.spaceId },
data: { updatedAt: now },
});
}
const event = await appendEventInTransaction(tx, {
...input,
type: "thread.cleared",
payload: {},
});
return {
event,
cancelledRunIds: runIds,
historyCompactionGeneration: thread.historyCompactionGeneration,
};
});
await notifyRealtime(realtime, committed.event.threadId, committed.event.seq);
return {
event: mapProductEvent(committed.event),
cancelledRunIds: committed.cancelledRunIds,
historyCompactionGeneration: committed.historyCompactionGeneration,
};
}
export async function sendUserMessage(
prisma: PrismaClient,
input: SendUserMessageInput,
realtime?: RealtimeFanout,
): Promise<SendUserMessageResult> {
const replay = async (): Promise<SendUserMessageResult | null> => {
if (!input.clientNonce) return null;
const message = await prisma.message.findUnique({
where: {
threadId_clientNonce: {
threadId: input.threadId,
clientNonce: input.clientNonce,
},
},
include: { sourceRuns: { orderBy: { createdAt: "asc" }, take: 1 } },
});
if (!message) return null;
const created = message.sourceRuns[0];
const run =
created ??
(message.runId ? await prisma.run.findUnique({ where: { id: message.runId } }) : null);
return {
messageId: message.id,
seq: message.seq,
taskId: created?.taskId ?? null,
runId: run?.id ?? null,
};
};
const existing = await replay();
if (existing) return existing;
const commit = () =>
prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Message first: its thread-row lock serializes the whole send against clearThread, so a
// concurrent clear either sees the committed run and cancels it, or strictly precedes this
// transaction. Created in separate transactions, the run could land inside the clear's
// window and later repopulate the cleared conversation, and a clear could strand the
// message without its event.
const message = await createThreadMessageInTransaction(tx, {
threadId: input.threadId,
role: "user",
blocks: input.blocks,
clientNonce: input.clientNonce,
});
const busy = await tx.run.findFirst({
where: {
threadId: input.threadId,
botId: input.botId,
status: { in: ["running", "queued", "leased", "waiting_input", "waiting_takeover"] },
},
select: { id: true, taskId: true },
});
let task = null;
let run = null;
if (!busy) {
task = await tx.task.create({
data: {
spaceId: input.spaceId,
botId: input.botId,
threadId: input.threadId,
userId: input.userId,
prompt: input.prompt,
status: "queued",
},
});
run = await tx.run.create({
data: {
spaceId: input.spaceId,
botId: input.botId,
threadId: input.threadId,
taskId: task.id,
userId: input.userId,
status: "queued",
trigger: input.trigger,
clientNonce: input.clientNonce ? `send:${message.id}` : undefined,
sourceMessageId: message.id,
},
});
if (input.linkMessageToRun) {
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
}
} else {
await tx.steeringMessage.create({
data: {
messageId: message.id,
botId: input.botId,
userId: input.userId,
runId: busy.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: busy.id } });
}
const event = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "thread.message.created",
runId: run?.id ?? busy?.id,
payload: { messageId: message.id, role: "user", blocks: input.blocks },
});
return { message, task, run, busy, event };
});
const committed = await commit().catch(async (error) => {
const winner = await replay();
if (winner) return { replay: winner } as const;
throw error;
});
if ("replay" in committed) return committed.replay;
await notifyRealtime(realtime, input.threadId, committed.event.seq).catch((error) => {
// The event is durable; subscribers recover it from their persisted cursor.
console.error("user message realtime notification", error);
});
return {
messageId: committed.message.id,
seq: committed.message.seq,
taskId: committed.task?.id ?? null,
runId: committed.run?.id ?? committed.busy?.id ?? null,
};
}
export async function claimSteering(
prisma: PrismaClient,
input: ClaimSteeringInput,
): Promise<ClaimedSteeringMessage[]> {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`;
const run = await tx.run.findFirst({
where: {
id: input.runId,
threadId: input.threadId,
botId: input.botId,
status: "running",
leaseOwner: input.leaseOwner,
leaseFence: input.leaseFence,
},
select: { id: true },
});
if (!run) return [];
const steering = await tx.steeringMessage.findMany({
where: {
botId: input.botId,
id: input.seenIds.length ? { notIn: input.seenIds } : undefined,
OR: [{ runId: null }, { runId: input.runId }],
message: { threadId: input.threadId },
},
include: { message: { select: { blocks: true, seq: true } } },
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
if (steering.length === 0) return [];
await tx.steeringMessage.updateMany({
where: { id: { in: steering.map((item) => item.id) }, claimedAt: null },
data: { runId: input.runId, claimedAt: new Date() },
});
return steering.map((item) => ({
id: item.id,
messageId: item.messageId,
text: blocksToAgentHistoryText(item.message.blocks as MessageBlock[]),
blocks: item.message.blocks as MessageBlock[],
}));
});
}
export async function answerRunInput(
prisma: PrismaClient,
input: AnswerRunInput,
realtime?: RealtimeFanout,
runSecretWriter?: RunSecretWriter,
): Promise<boolean> {
const committed = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Thread row first, then run rows — the same order as clearThread and finalizeRun, so a
// concurrent clear cannot deadlock against this transaction.
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`;
const run = await tx.run.findFirst({
where: {
id: input.runId,
spaceId: input.spaceId,
threadId: input.threadId,
status: "waiting_input",
},
select: { botId: true, userId: true, checkpoint: true },
});
if (!run) return null;
const message = await tx.message.findFirst({
where: {
id: input.messageId,
threadId: input.threadId,
runId: input.runId,
role: "bot",
},
});
const parsed = MessageBlockSchema.array().safeParse(message?.blocks);
if (!message || !parsed.success) return null;
const pendingAsk = parsed.data.find(
(block) => block.kind === "ask" && block.status !== "answered",
);
if (pendingAsk?.kind !== "ask") return null;
const approvalAsk = isApprovalAskBlock(pendingAsk);
const secretAsk = isSecretAskBlock(pendingAsk);
const choiceAsk = !approvalAsk && !secretAsk && Boolean(pendingAsk.actions?.length);
const selectedChoice = choiceAsk
? pendingAsk.actions?.find((action) => action.id === input.answer)
: undefined;
if (choiceAsk && !selectedChoice) return null;
if (secretAsk && !runSecretWriter) return null;
let approvalEffect: { id: string; kind: string } | null = null;
let approvalUserId: string | null = null;
if (approvalAsk) {
if (!pendingAsk.actions?.some((action) => action.id === input.answer)) return null;
approvalEffect = await tx.externalEffect.findFirst({
where: {
id: pendingAsk.approvalEffectId,
spaceId: input.spaceId,
runId: input.runId,
status: "intended",
},
});
if (!approvalEffect) return null;
if (input.answer === "always") {
if (run.userId !== input.answeredByUserId) return null;
approvalUserId = input.answeredByUserId;
}
}
const queued = await tx.run.updateMany({
where: {
id: input.runId,
spaceId: input.spaceId,
threadId: input.threadId,
status: "waiting_input",
},
data: {
status: "queued",
...(choiceAsk ? { checkpoint: null } : {}),
},
});
if (queued.count !== 1) return null;
if (approvalAsk) {
const allowed = input.answer === "allow" || input.answer === "always";
await tx.externalEffect.update({
where: { id: approvalEffect!.id },
data: { status: allowed ? "approved" : "denied" },
});
if (input.answer === "always") {
await tx.actionApprovalRule.upsert({
where: {
spaceId_createdByUserId_effect_matchKind_matchValue: {
spaceId: input.spaceId,
createdByUserId: approvalUserId!,
effect: "always_allow",
matchKind: "tool",
matchValue: approvalEffect!.kind,
},
},
create: {
spaceId: input.spaceId,
createdByUserId: approvalUserId!,
effect: "always_allow",
matchKind: "tool",
matchValue: approvalEffect!.kind,
},
update: {},
});
}
} else if (secretAsk) {
await runSecretWriter!.store({
runId: input.runId,
userId: run.userId,
spaceId: input.spaceId,
plaintext: input.answer,
tx,
});
await tx.externalEffect.updateMany({
where: {
runId: input.runId,
spaceId: input.spaceId,
kind: "request_secret",
status: "intended",
},
data: { status: "approved" },
});
} else {
const resumeLabel = selectedChoice
? resumeChoiceLabel(selectedChoice, run.checkpoint)
: undefined;
const task = await tx.task.updateMany({
where: { runs: { some: { id: input.runId } } },
data: {
prompt: selectedChoice
? `Selected choice ${selectedChoice.id}: ${resumeLabel}`
: input.answer,
},
});
if (task.count !== 1) throw new Error("Run task was not available to answer");
}
const blocks = parsed.data.map((block) =>
block === pendingAsk
? {
...block,
status: "answered" as const,
answer: secretAsk ? "" : input.answer,
}
: block,
);
await tx.message.update({ where: { id: message.id }, data: { blocks } });
const updated = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: run.botId,
type: "thread.message.updated",
runId: input.runId,
payload: { messageId: message.id, role: "bot", blocks },
});
return { threadId: updated.threadId, seq: updated.seq };
});
if (!committed) return false;
await notifyRealtime(realtime, committed.threadId, committed.seq);
return true;
}
export async function pauseRunForInput(
prisma: PrismaClient,
input: PauseRunForInput,
realtime?: RealtimeFanout,
): Promise<boolean> {
const committed = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Thread row first, then run rows — the same order as clearThread and finalizeRun, so a
// concurrent clear cannot deadlock against this transaction.
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`;
const paused = await tx.run.updateMany({
where: {
id: input.runId,
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
status: "running",
leaseOwner: input.leaseOwner,
leaseFence: input.leaseFence,
},
data: {
status: "waiting_input",
leaseOwner: null,
leaseExpiresAt: null,
...(input.offeredActions?.length
? { checkpoint: choiceAskCheckpoint(input.offeredActions) }
: {}),
},
});
if (paused.count !== 1) return null;
const attempt = await tx.attempt.updateMany({
where: {
id: input.attemptId,
runId: input.runId,
fence: input.leaseFence,
status: "running",
},
data: { status: "waiting_input", finishedAt: new Date() },
});
if (attempt.count !== 1) throw new Error("Active run attempt was not available to pause");
const message = await createThreadMessageInTransaction(tx, {
threadId: input.threadId,
role: "bot",
blocks: input.blocks,
botId: input.botId,
runId: input.runId,
});
await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "thread.message.created",
runId: input.runId,
payload: { messageId: message.id, role: "bot", blocks: input.blocks },
});
const waitingEvent = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "run.waiting_input",
runId: input.runId,
payload: {},
});
await tx.event.deleteMany({ where: { runId: input.runId, type: "thread.progress" } });
return { threadId: waitingEvent.threadId, seq: waitingEvent.seq };
});
if (!committed) return false;
await notifyRealtime(realtime, committed.threadId, committed.seq);
return true;
}
export async function pauseRunForTakeover(
prisma: PrismaClient,
input: PauseRunForTakeover,
realtime?: RealtimeFanout,
): Promise<boolean> {
const committed = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`;
const paused = await tx.run.updateMany({
where: {
id: input.runId,
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
status: "running",
leaseOwner: input.leaseOwner,
leaseFence: input.leaseFence,
},
data: {
status: "waiting_takeover",
leaseOwner: null,
leaseExpiresAt: null,
checkpoint: null,
},
});
if (paused.count !== 1) return null;
const attempt = await tx.attempt.updateMany({
where: {
id: input.attemptId,
runId: input.runId,
fence: input.leaseFence,
status: "running",
},
data: { status: "waiting_takeover", finishedAt: new Date() },
});
if (attempt.count !== 1) throw new Error("Active run attempt was not available to pause");
const waitingEvent = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "computer.takeover.requested",
runId: input.runId,
payload: { reason: input.reason },
});
await tx.event.deleteMany({ where: { runId: input.runId, type: "thread.progress" } });
return { threadId: waitingEvent.threadId, seq: waitingEvent.seq };
});
if (!committed) return false;
await notifyRealtime(realtime, committed.threadId, committed.seq);
return true;
}
export async function finalizeComputerControlRelease(
prisma: PrismaClient,
input: FinalizeComputerControlReleaseInput,
realtime?: RealtimeFanout,
): Promise<FinalizeComputerControlReleaseResult | false> {
const committed = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const cleared = await tx.computer.updateMany({
where: {
id: input.computerId,
spaceId: input.spaceId,
controlBotId: input.botId,
controlLeaseId: input.leaseId,
controlRunId: input.runId,
},
data: {
controlHolder: input.holder,
controlLeaseId: null,
controlLeaseExpiresAt: null,
controlBotId: null,
controlRunId: null,
},
});
if (cleared.count !== 1) return null;
const resumed = input.runId
? await tx.run.updateMany({
where: {
id: input.runId,
spaceId: input.spaceId,
botId: input.botId,
status: "waiting_takeover",
},
data: {
status: "queued",
checkpoint:
input.reason === "skipped" || input.reason === "expired"
? "takeover-skipped"
: "takeover",
},
})
: { count: 0 };
const runId = resumed.count === 1 ? input.runId : null;
const bot = await tx.bot.findFirst({
where: { id: input.botId, spaceId: input.spaceId },
select: { thread: { select: { id: true } } },
});
if (!bot?.thread) return { threadId: null, seq: null, runId };
const event = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: bot.thread.id,
botId: input.botId,
runId: runId ?? undefined,
type: "computer.takeover.released",
payload: {
holder: input.holder,
leaseId: input.leaseId,
reason: input.reason,
},
});
return { threadId: event.threadId, seq: event.seq, runId };
});
if (!committed) return false;
if (committed.threadId && committed.seq !== null) {
await notifyRealtime(realtime, committed.threadId, committed.seq);
}
return { runId: committed.runId };
}
export async function appendEvent(
prisma: PrismaClient,
input: AppendEventInput,
realtime?: RealtimeFanout,
): Promise<ProductEvent> {
const event = await prisma.$transaction((tx: Prisma.TransactionClient) =>
appendEventInTransaction(tx, input),
);
const productEvent = mapProductEvent(event);
await notifyRealtime(realtime, event.threadId, event.seq);
return productEvent;
}
export async function finalizeRun(
prisma: PrismaClient,
input: FinalizeRunInput,
realtime?: RealtimeFanout,
): Promise<FinalizeRunResult | false> {
const committed = await withTransactionRetry(() => finalizeRunOnce(prisma, input));
if (!committed) return false;
await notifyRealtime(realtime, committed.threadId, committed.seq);
return { continuationRunId: committed.continuationRunId };
}
async function finalizeRunOnce(
prisma: PrismaClient,
input: FinalizeRunInput,
): Promise<{ threadId: string; seq: number; continuationRunId: string | null } | null> {
return prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${input.threadId} FOR UPDATE`;
try {
await assertRunCanWriteHistory(tx, input.runId);
} catch (error) {
if (error instanceof RunHistoryWriteError) return null;
throw error;
}
const now = new Date();
const terminal = await tx.run.updateMany({
where: {
id: input.runId,
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
taskId: input.taskId,
status: "running",
leaseOwner: input.leaseOwner,
leaseFence: input.leaseFence,
},
data: {
status: input.outcome,
error: input.outcome === "failed" ? input.error : null,
completedAt: now,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (terminal.count !== 1) return null;
const attempt = await tx.attempt.updateMany({
where: {
id: input.attemptId,
runId: input.runId,
fence: input.leaseFence,
status: "running",
},
data: {
status: input.outcome,
error: input.outcome === "failed" ? input.error : null,
finishedAt: now,
},
});
if (attempt.count !== 1) throw new Error("Active run attempt was not available to finalize");
const task = await tx.task.updateMany({
where: {
id: input.taskId,
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
},
data: { status: input.outcome },
});
if (task.count !== 1) throw new Error("Run task was not available to finalize");
if (input.outcome === "completed" && input.blocks.length > 0) {
const message = await createThreadMessageInTransaction(tx, {
threadId: input.threadId,
role: "bot",
blocks: input.blocks,
botId: input.botId,
runId: input.runId,
markUnread: input.markUnread,
});
await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: "thread.message.created",
runId: input.runId,
payload: { messageId: message.id, role: "bot", blocks: input.blocks },
});
}
const lastEvent = await appendEventInTransaction(tx, {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
type: input.outcome === "completed" ? "run.completed" : "run.failed",
runId: input.runId,
payload: input.outcome === "completed" ? {} : { error: input.error },
});
await tx.event.deleteMany({ where: { runId: input.runId, type: "thread.progress" } });
if (input.outcome === "completed") {
await tx.steeringMessage.deleteMany({
where: { runId: input.runId, claimedAt: { not: null } },
});
await tx.steeringMessage.updateMany({
where: { runId: input.runId },
data: { runId: null },
});
} else {
await tx.steeringMessage.updateMany({
where: { runId: input.runId },
data: { runId: null },
});
}
const continuationRunId = await createSteeringContinuation(tx, input);
await tx.bot.update({ where: { id: input.botId }, data: { updatedAt: now } });
return { threadId: lastEvent.threadId, seq: lastEvent.seq, continuationRunId };
});
}
async function createSteeringContinuation(
tx: Prisma.TransactionClient,
input: FinalizeRunBase,
): Promise<string | null> {
const active = await tx.run.findFirst({
where: {
threadId: input.threadId,
botId: input.botId,
status: { in: ["queued", "leased", "running", "waiting_input", "waiting_takeover"] },
},
select: { id: true },
});
if (active) return null;
const pending = await tx.steeringMessage.findMany({
where: {
botId: input.botId,
runId: null,
message: { threadId: input.threadId },
},
include: { message: { select: { id: true, blocks: true, seq: true } } },
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
if (pending.length === 0) return null;
const last = pending.at(-1)!;
const task = await tx.task.create({
data: {
spaceId: input.spaceId,
botId: input.botId,
threadId: input.threadId,
userId: pending[0]!.userId,
prompt: "Respond to the user's steering context.",
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: input.spaceId,
botId: input.botId,
threadId: input.threadId,
taskId: task.id,
userId: pending[0]!.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: last.message.id,
},
});
await tx.steeringMessage.updateMany({
where: { id: { in: pending.map((item) => item.id) }, runId: null },
data: { runId: run.id, claimedAt: null },
});
return run.id;
}
export async function appendEventInTransaction(
tx: Prisma.TransactionClient,
input: AppendEventInput,
) {
const thread = await tx.thread.update({
where: { id: input.threadId },
data: { nextEventSeq: { increment: 1 } },
select: { nextEventSeq: true },
});
await assertRunCanWriteHistory(tx, input.runId);
// Unpaired UTF-16 surrogates (e.g. a split emoji high half) are invalid JSON for Postgres.
const payload = sanitizeJsonValue(input.payload);
return tx.event.create({
data: {
spaceId: input.spaceId,
threadId: input.threadId,
botId: input.botId,
seq: thread.nextEventSeq - 1,
type: input.type,
payload: payload as Prisma.InputJsonValue,
runId: input.runId,
},
});
}
async function notifyRealtime(
realtime: RealtimeFanout | undefined,
threadId: string,
cursor: number,
): Promise<void> {
await realtime?.publish(threadTopic(threadId), JSON.stringify({ cursor })).catch(() => undefined);
}
export async function eventsAfter(
prisma: PrismaClient,
threadId: string,
cursor: number,
limit?: number,
) {
return prisma.event.findMany({
where: { threadId, seq: { gt: cursor } },
orderBy: { seq: "asc" },
...(limit ? { take: limit } : {}),
});
}
export async function* followThreadEvents(
prisma: PrismaClient,
threadId: string,
cursor: number,
realtime?: RealtimeFanout,
signal?: AbortSignal,
catchUpMs = realtime ? PUSH_CATCH_UP_MS : POLL_ONLY_CATCH_UP_MS,
): AsyncGenerator<ProductEvent> {
let seq = cursor;
const latch = new ChangeLatch();
const unsubscribe = realtime
? await realtime
.subscribe(threadTopic(threadId), () => latch.notify())
.catch(() => async () => {})
: async () => {};
try {
while (!signal?.aborted) {
const observedGeneration = latch.generation;
let batchSize = 0;
do {
const events = await eventsAfter(prisma, threadId, seq, EVENT_BATCH_SIZE);
batchSize = events.length;
for (const event of events) {
seq = event.seq;
yield mapProductEvent(event);
}
} while (batchSize === EVENT_BATCH_SIZE && !signal?.aborted);
if (signal?.aborted) break;
await latch.waitForChange(observedGeneration, catchUpMs, signal);
}
} finally {
await unsubscribe();
}
}
function threadTopic(threadId: string): string {
return `thread:${threadId}`;
}
function mapProductEvent(event: {
id: string;
spaceId: string;
threadId: string;
botId: string;
seq: number;
type: string;
payload: unknown;
runId: string | null;
createdAt: Date;
}): ProductEvent {
return {
id: event.id,
spaceId: event.spaceId,
threadId: event.threadId,
botId: event.botId,
seq: event.seq,
type: event.type as ProductEvent["type"],
runId: event.runId ?? undefined,
createdAt: event.createdAt.toISOString(),
payload: event.payload as Record<string, unknown>,
};
}
class ChangeLatch {
generation = 0;
private wake: (() => void) | undefined;
notify(): void {
this.generation += 1;
this.wake?.();
}
async waitForChange(expected: number, timeoutMs: number, signal?: AbortSignal): Promise<void> {
if (this.generation !== expected || signal?.aborted) return;
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", finish);
if (this.wake === finish) this.wake = undefined;
resolve();
};
const timer = setTimeout(finish, timeoutMs);
this.wake = finish;
signal?.addEventListener("abort", finish, { once: true });
if (this.generation !== expected || signal?.aborted) finish();
});
}
}