1178 lines
36 KiB
TypeScript
1178 lines
36 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|