285 lines
7.7 KiB
TypeScript
285 lines
7.7 KiB
TypeScript
import { AssistantApprovalStatus, Prisma, type PrismaClient } from "@capakraken/db";
|
|
import { logger } from "../lib/logger.js";
|
|
import { buildApprovalSummary } from "./assistant-confirmation.js";
|
|
|
|
const PENDING_APPROVAL_TTL_MS = 15 * 60 * 1000;
|
|
const ASSISTANT_APPROVALS_TABLE_NAME = "public.assistant_approvals";
|
|
let hasLoggedAssistantApprovalStorageUnavailable = false;
|
|
|
|
export type AssistantApprovalStore = Pick<PrismaClient, "assistantApproval">;
|
|
|
|
export class AssistantApprovalStorageUnavailableError extends Error {
|
|
constructor() {
|
|
super("Assistant approval storage is unavailable.");
|
|
this.name = "AssistantApprovalStorageUnavailableError";
|
|
}
|
|
}
|
|
|
|
export interface PendingAssistantApproval {
|
|
id: string;
|
|
userId: string;
|
|
conversationId: string;
|
|
toolName: string;
|
|
toolArguments: string;
|
|
summary: string;
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export interface AssistantApprovalPayload {
|
|
id: string;
|
|
status: "pending" | "approved" | "cancelled";
|
|
conversationId: string;
|
|
toolName: string;
|
|
summary: string;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
}
|
|
|
|
type AssistantApprovalRecord = {
|
|
id: string;
|
|
userId: string;
|
|
conversationId: string;
|
|
toolName: string;
|
|
toolArguments: string;
|
|
summary: string;
|
|
createdAt: Date;
|
|
expiresAt: Date;
|
|
};
|
|
|
|
function mapPendingApproval(record: AssistantApprovalRecord): PendingAssistantApproval {
|
|
return {
|
|
id: record.id,
|
|
userId: record.userId,
|
|
conversationId: record.conversationId,
|
|
toolName: record.toolName,
|
|
toolArguments: record.toolArguments,
|
|
summary: record.summary,
|
|
createdAt: record.createdAt.getTime(),
|
|
expiresAt: record.expiresAt.getTime(),
|
|
};
|
|
}
|
|
|
|
export function toApprovalPayload(
|
|
approval: PendingAssistantApproval,
|
|
status: AssistantApprovalPayload["status"],
|
|
): AssistantApprovalPayload {
|
|
return {
|
|
id: approval.id,
|
|
status,
|
|
conversationId: approval.conversationId,
|
|
toolName: approval.toolName,
|
|
summary: approval.summary,
|
|
createdAt: new Date(approval.createdAt).toISOString(),
|
|
expiresAt: new Date(approval.expiresAt).toISOString(),
|
|
};
|
|
}
|
|
|
|
function isAssistantApprovalTableMissingError(error: unknown): boolean {
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (error.code !== "P2021") return false;
|
|
const table = typeof error.meta?.table === "string" ? error.meta.table : "";
|
|
return table.includes("assistant_approvals") || error.message.includes("assistant_approvals");
|
|
}
|
|
|
|
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
return false;
|
|
}
|
|
|
|
const candidate = error as {
|
|
code?: unknown;
|
|
message?: unknown;
|
|
meta?: {
|
|
table?: unknown;
|
|
};
|
|
};
|
|
const code = typeof candidate.code === "string" ? candidate.code : "";
|
|
if (code !== "P2021") return false;
|
|
|
|
const message = typeof candidate.message === "string"
|
|
? candidate.message
|
|
: "";
|
|
const metaTable = typeof candidate.meta?.table === "string"
|
|
? candidate.meta.table
|
|
: "";
|
|
|
|
return metaTable.includes("assistant_approvals") || message.includes("assistant_approvals");
|
|
}
|
|
|
|
function logAssistantApprovalStorageUnavailable(error: unknown) {
|
|
if (hasLoggedAssistantApprovalStorageUnavailable) {
|
|
return;
|
|
}
|
|
hasLoggedAssistantApprovalStorageUnavailable = true;
|
|
logger.warn(
|
|
{
|
|
err: error,
|
|
table: ASSISTANT_APPROVALS_TABLE_NAME,
|
|
},
|
|
"Assistant approval storage is unavailable",
|
|
);
|
|
}
|
|
|
|
async function withAssistantApprovalFallback<T>(
|
|
operation: () => Promise<T>,
|
|
fallback: () => T,
|
|
): Promise<T> {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
if (!isAssistantApprovalTableMissingError(error)) throw error;
|
|
logAssistantApprovalStorageUnavailable(error);
|
|
return fallback();
|
|
}
|
|
}
|
|
|
|
export function resetAssistantApprovalStorageWarningStateForTests(): void {
|
|
hasLoggedAssistantApprovalStorageUnavailable = false;
|
|
}
|
|
|
|
export async function listPendingAssistantApprovals(
|
|
db: AssistantApprovalStore,
|
|
userId: string,
|
|
): Promise<PendingAssistantApproval[]> {
|
|
return withAssistantApprovalFallback(async () => {
|
|
await db.assistantApproval.updateMany({
|
|
where: {
|
|
userId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
expiresAt: { lte: new Date() },
|
|
},
|
|
data: {
|
|
status: AssistantApprovalStatus.EXPIRED,
|
|
},
|
|
});
|
|
|
|
const approvals = await db.assistantApproval.findMany({
|
|
where: {
|
|
userId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
expiresAt: { gt: new Date() },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
|
|
return approvals.map(mapPendingApproval);
|
|
}, () => []);
|
|
}
|
|
|
|
export async function clearPendingAssistantApproval(
|
|
db: AssistantApprovalStore,
|
|
userId: string,
|
|
conversationId: string,
|
|
): Promise<void> {
|
|
await withAssistantApprovalFallback(async () => {
|
|
await db.assistantApproval.updateMany({
|
|
where: {
|
|
userId,
|
|
conversationId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
},
|
|
data: {
|
|
status: AssistantApprovalStatus.CANCELLED,
|
|
cancelledAt: new Date(),
|
|
},
|
|
});
|
|
}, () => undefined);
|
|
}
|
|
|
|
export async function peekPendingAssistantApproval(
|
|
db: AssistantApprovalStore,
|
|
userId: string,
|
|
conversationId: string,
|
|
): Promise<PendingAssistantApproval | null> {
|
|
return withAssistantApprovalFallback(async () => {
|
|
await db.assistantApproval.updateMany({
|
|
where: {
|
|
userId,
|
|
conversationId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
expiresAt: { lte: new Date() },
|
|
},
|
|
data: {
|
|
status: AssistantApprovalStatus.EXPIRED,
|
|
},
|
|
});
|
|
|
|
const pending = await db.assistantApproval.findFirst({
|
|
where: {
|
|
userId,
|
|
conversationId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
if (!pending) return null;
|
|
return mapPendingApproval(pending);
|
|
}, () => null);
|
|
}
|
|
|
|
export async function consumePendingAssistantApproval(
|
|
db: AssistantApprovalStore,
|
|
userId: string,
|
|
conversationId: string,
|
|
): Promise<PendingAssistantApproval | null> {
|
|
const pending = await peekPendingAssistantApproval(db, userId, conversationId);
|
|
if (!pending) return null;
|
|
const approvedAt = new Date();
|
|
const updateResult = await db.assistantApproval.updateMany({
|
|
where: {
|
|
id: pending.id,
|
|
userId,
|
|
conversationId,
|
|
status: AssistantApprovalStatus.PENDING,
|
|
expiresAt: { gt: approvedAt },
|
|
},
|
|
data: {
|
|
status: AssistantApprovalStatus.APPROVED,
|
|
approvedAt,
|
|
},
|
|
});
|
|
if (updateResult.count === 0) return null;
|
|
|
|
const approved = await db.assistantApproval.findFirst({
|
|
where: {
|
|
id: pending.id,
|
|
userId,
|
|
conversationId,
|
|
},
|
|
});
|
|
if (!approved) return null;
|
|
return mapPendingApproval(approved);
|
|
}
|
|
|
|
export async function createPendingAssistantApproval(
|
|
db: AssistantApprovalStore,
|
|
userId: string,
|
|
conversationId: string,
|
|
toolName: string,
|
|
toolArguments: string,
|
|
options?: { summary?: string; ttlMs?: number },
|
|
): Promise<PendingAssistantApproval> {
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + (options?.ttlMs ?? PENDING_APPROVAL_TTL_MS));
|
|
const summary = options?.summary ?? buildApprovalSummary(toolName, toolArguments);
|
|
try {
|
|
await clearPendingAssistantApproval(db, userId, conversationId);
|
|
const pendingApproval = await db.assistantApproval.create({
|
|
data: {
|
|
userId,
|
|
conversationId,
|
|
toolName,
|
|
toolArguments,
|
|
summary,
|
|
createdAt: now,
|
|
expiresAt,
|
|
},
|
|
});
|
|
return mapPendingApproval(pendingApproval);
|
|
} catch (error) {
|
|
if (!isAssistantApprovalTableMissingError(error)) throw error;
|
|
logAssistantApprovalStorageUnavailable(error);
|
|
throw new AssistantApprovalStorageUnavailableError();
|
|
}
|
|
}
|