diff --git a/doc/performance/2026-11-04-bound-chat-prepared-query.md b/doc/performance/2026-11-04-bound-chat-prepared-query.md new file mode 100644 index 00000000000..c4cc3e1096c --- /dev/null +++ b/doc/performance/2026-11-04-bound-chat-prepared-query.md @@ -0,0 +1,79 @@ +# Bound-chat authorization query compilation + +`authorizeChatConversationForBoundRun` reads four joined tables to establish the +current endpoint, conversation, delivery and comment binding. It runs in shared +external-review wait and attention-retention paths. In the November 3 staging +sample, this SQL shape accounted for 16.2% of sampled SQL serialization CPU +(32 builds, mean 478.5 microseconds). That share describes SQL serialization, +not total server CPU or request latency. + +The service now retains its Drizzle compiled query in a `WeakMap` keyed by the +exact supplied database or transaction object. It creates only the requested +read, `FOR UPDATE`, or `FOR UPDATE NOWAIT` variant. Each execution supplies fresh +company, issue, agent, provider and comment IDs. An `ANY(uuid[])` placeholder +keeps the template count at three per live handle for every comment-list size. +It retains no result or authorization decision. Transactions and nested +savepoints cannot borrow a root query session. Setup, credential, resource and +principal checks still read current state. + +## Reproduce + +From the source checkout with dependencies and the plugin SDK built: + +```sh +node --import ./server/node_modules/tsx/dist/loader.mjs \ + server/scripts/benchmark-bound-chat-authorization.ts \ + --baseline-ref=e54f9fbf828e06e385e0c62dc381ccb46190aa30 +``` + +Use Node 24.22.2 to match this measurement. The command seeds an isolated real +PostgreSQL database and invokes the actual service function. It imports the +baseline module from the supplied Git revision with its relative imports +resolved against the same checkout. It uses one active Slack conversation, +one processed message, a linked member and an enabled destination. It warms +each implementation with 500 successful calls, then alternates order across +seven pairs of 500 successful calls per implementation. Each result is checked. +Query compilation counts are measured separately after all timed rounds. + +## Recorded result + +Baseline: `e54f9fbf828e06e385e0c62dc381ccb46190aa30`. +Treatment: `8386eb78d118faaf6c2fd7f7471b38c0f4b2ef54`. +Node 24.22.2, macOS arm64, source execution through tsx. + +| Metric per 500 calls | Baseline median (range), ms | Treatment median (range), ms | Median change | +| --- | --- | --- | --- | +| Node process CPU | 379.033 (286.092–424.702) | 277.048 (247.341–332.123) | -26.91% | +| Wall time | 742.109 (557.337–901.323) | 648.005 (560.198–662.265) | -12.68% | + +CPU improved in all seven matched pairs, by 13.54–33.52%. One wall-time pair +increased by 0.51%. Warm-call SQL compilations fell from five to four: the +joined authorization query no longer recompiles. All database reads still run. + +The owning team stopped other builds and tests during this measurement. +Unrelated Rust compilation was observed on the host. Process CPU excludes those +other processes, but shared host contention can affect scheduling, caches and +wall time. This local source-mode fixture does not establish a production +latency improvement, a whole-server CPU reduction, or a deployed result. It +measures repeated calls on one live root handle. A new transaction still pays +its first compilation cost. An alternating measurement does not remove every +host-load effect. + +## Correctness and retained setup failures + +The focused real PostgreSQL suite passed 17 tests: four new authorization tests +and 13 existing attachment-reuse tests. The new cases exercise changed binding +values and comment arities, duplicate and empty inputs, invalid UUID errors, +company/agent/provider isolation, current setup generation and credential +fingerprints, transaction and savepoint rollback, read mode during a held row +lock, blocking lock timeout and NOWAIT contention. + +The first attempt failed before test collection because plugin SDK build output +was absent. Building shared and plugin SDK packages corrected that prerequisite. +Subsequent attempts failed before assertions because the installed embedded +PostgreSQL package lacked version aliases for bundled native libraries. Setting +`DYLD_LIBRARY_PATH` did not fix that child-process setup. Restoring the missing +aliases inside the isolated worktree's dependency directory corrected it; no +repository dependency, lockfile or production source workaround was added. +Those setup failures are not passing behavior evidence. The final 17-test run +passed after the local repair. The benchmark then completed with empty stderr. diff --git a/server/scripts/benchmark-bound-chat-authorization.ts b/server/scripts/benchmark-bound-chat-authorization.ts new file mode 100644 index 00000000000..63606105dca --- /dev/null +++ b/server/scripts/benchmark-bound-chat-authorization.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { SQL } from "drizzle-orm"; +import { createDb } from "@paperclipai/db"; +import { seedBoundChatAuthorization } from "../src/__tests__/helpers/bound-chat-authorization.js"; +import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js"; +import { authorizeChatConversationForBoundRun as treatment } from "../src/services/native-runtime/chat-attachment-reuse.js"; + +const baselineRef = process.argv.find((arg) => arg.startsWith("--baseline-ref="))?.slice(15); +assert.ok(baselineRef, "Pass --baseline-ref="); +const root = fileURLToPath(new URL("../../", import.meta.url)); +const baselineRevision = execFileSync("git", ["rev-parse", `${baselineRef}^{commit}`], { cwd: root, encoding: "utf8" }).trim(); +const treatmentRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); +const modulePath = "server/src/services/native-runtime/chat-attachment-reuse.ts"; +const source = execFileSync("git", ["show", `${baselineRevision}:${modulePath}`], { cwd: root, encoding: "utf8" }); +const currentSource = await readFile(path.join(root, modulePath), "utf8"); +assert.notEqual(source, currentSource, "Baseline must precede the optimization"); +const baselineDir = await mkdtemp(path.join(root, "server/scripts/.bound-chat-baseline-")); +const baselineFile = path.join(baselineDir, "chat-attachment-reuse.ts"); +const moduleUrl = pathToFileURL(path.join(root, modulePath)); +await writeFile(baselineFile, source.replace(/from "(\.{1,2}\/[^\"]+)"/g, + (_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, moduleUrl)))}`)); +const keepAlive = setInterval(() => {}, 1_000); +let temporary: Awaited> | undefined; +let db: ReturnType | undefined; +try { + const { authorizeChatConversationForBoundRun: baseline } = await import(pathToFileURL(baselineFile).href) as + typeof import("../src/services/native-runtime/chat-attachment-reuse.js"); + temporary = await startEmbeddedPostgresTestDatabase("bound-chat-benchmark-"); + db = createDb(temporary.connectionString); + const fixture = await seedBoundChatAuthorization(db); + const readers = { baseline, treatment }; + const expected = { endpointId: fixture.endpointId, conversationId: fixture.conversationId }; + for (const reader of Object.values(readers)) { + for (let n = 0; n < 500; n++) assert.deepEqual(await reader(db, fixture.binding, fixture.context, "read"), expected); + } + const iterations = 500; + const samples = []; + for (let round = 0; round < 7; round++) { + for (const label of round % 2 ? ["treatment", "baseline"] as const : ["baseline", "treatment"] as const) { + const cpuStart = process.cpuUsage(), start = performance.now(); + for (let n = 0; n < iterations; n++) { + assert.deepEqual(await readers[label](db, fixture.binding, fixture.context, "read"), expected); + } + const wallMs = performance.now() - start, cpu = process.cpuUsage(cpuStart); + const sample = { round, label, iterations, cpuMs: (cpu.user + cpu.system) / 1_000, wallMs }; + samples.push(sample); + process.stdout.write(JSON.stringify(sample) + "\n"); + } + } + const compilationCounts: Record = {}; + const original = SQL.prototype.toQuery; + for (const label of ["baseline", "treatment"] as const) { + let compilations = 0; + SQL.prototype.toQuery = function (...args) { + compilations++; + return Reflect.apply(original, this, args); + }; + try { + assert.deepEqual(await readers[label](db, fixture.binding, fixture.context, "read"), expected); + } finally { SQL.prototype.toQuery = original; } + compilationCounts[label] = compilations; + } + assert.equal(compilationCounts.baseline - compilationCounts.treatment, 1); + process.stdout.write(JSON.stringify({ baselineRevision, treatmentRevision, node: process.version, + platform: process.platform, arch: process.arch, iterations, rounds: 7, warmups: 500, + data: "one active Slack conversation, processed inbound message, linked member, enabled destination", + surface: "authorizeChatConversationForBoundRun, real PostgreSQL, read mode, warmed exact root handle", + compilationCounts, samples }) + "\n"); +} finally { + await db?.$client.end({ timeout: 1 }); + await temporary?.cleanup(); + await rm(baselineDir, { recursive: true, force: true }); + clearInterval(keepAlive); +} diff --git a/server/src/__tests__/helpers/bound-chat-authorization.ts b/server/src/__tests__/helpers/bound-chat-authorization.ts new file mode 100644 index 00000000000..91cb1c1fe0c --- /dev/null +++ b/server/src/__tests__/helpers/bound-chat-authorization.ts @@ -0,0 +1,184 @@ +import { randomUUID } from "node:crypto"; +import { agents, companies, createDb, issues, issueComments, toolApplications, + toolConnections, chatEndpoints, chatEndpointResources, chatConversations, + chatExternalPrincipals, chatIdentityLinks, companyMemberships, chatDeliveries, + chatMessageLinks, type Db } from "@paperclipai/db"; +export async function seedBoundChatAuthorization(db: Db) { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), + otherIssueId = randomUUID(), runId = randomUUID(), endpointId = randomUUID(), + resourceId = randomUUID(), conversationId = randomUUID(), principalId = randomUUID(), + commentId = randomUUID(), deliveryId = randomUUID(); + const provider = "slack", userId = "prepared-chat-user"; + const binding = { companyId, agentId, issueId, runId }; + const context = { source: "chat:slack", paperclipHarnessCheckedOut: true, + wakeCommentIds: [commentId] }; + await db + .insert(companies) + .values({ + id: companyId, + name: "Reviewed chat", + issuePrefix: "RCB", + issueCounter: 1, + }); + await db + .insert(agents) + .values({ + id: agentId, + companyId, + name: "Chat runner", + adapterType: "paperclip_runner", + adapterConfig: { provider: "codex" }, + status: "active", + }); + await db + .insert(issues) + .values({ + id: issueId, + companyId, + title: "Reviewed conversation", + issueNumber: 1, + identifier: "RCB-1", + status: "in_review", + workMode: "standard", + assigneeAgentId: agentId, + }); + await db.insert(issues).values({ + id: otherIssueId, + companyId, + title: "Unrelated conversation", + issueNumber: 2, + identifier: "RCB-2", + status: "in_review", + assigneeAgentId: agentId, + }); + const applicationId = randomUUID(), + connectionId = randomUUID(); + await db + .insert(toolApplications) + .values({ + id: applicationId, + companyId, + applicationKey: `chat:${provider}:${endpointId}`, + name: "Discord", + type: "chat", + status: "active", + }); + await db + .insert(toolConnections) + .values({ + id: connectionId, + companyId, + applicationId, + name: "Discord", + uid: `chat-${provider}-${endpointId}`, + connectionPurpose: "channel", + transport: "chat_sdk", + status: "active", + enabled: true, + }); + await db + .insert(chatEndpoints) + .values({ + id: endpointId, + companyId, + connectionId, + provider, + publicId: randomUUID(), + assignedAgentId: agentId, + status: "active", + providerAccountId: "guild-1", + allowUnlinkedPeople: false, + }); + await db + .insert(chatEndpointResources) + .values({ + id: resourceId, + companyId, + endpointId, + type: "channel", + providerResourceId: "channel-1", + label: "#review", + availability: "available", + enabled: true, + }); + await db + .insert(chatConversations) + .values({ + id: conversationId, + companyId, + endpointId, + resourceId, + issueId, + externalConversationId: "channel-1", + externalThreadId: "thread-1", + externalLabel: "Review thread", + state: "active", + }); + await db + .insert(chatExternalPrincipals) + .values({ + id: principalId, + companyId, + provider, + providerAccountId: "guild-1", + externalId: "user-1", + kind: "user", + }); + await db + .insert(chatIdentityLinks) + .values({ + companyId, + endpointId, + principalId, + paperclipUserId: userId, + status: "linked", + }); + await db + .insert(companyMemberships) + .values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "member", + }); + await db + .insert(issueComments) + .values({ + id: commentId, + companyId, + issueId, + authorType: "user", + authorUserId: userId, + body: "Please inspect the earlier photo, without approving completion.", + }); + await db + .insert(chatDeliveries) + .values({ + id: deliveryId, + companyId, + endpointId, + conversationId, + principalId, + providerEventId: "review-followup", + deduplicationKey: "review-followup", + eventKind: "message", + normalizedEvent: {}, + state: "processed", + attempts: 1, + processedAt: new Date(), + }); + await db + .insert(chatMessageLinks) + .values({ + companyId, + endpointId, + conversationId, + deliveryId, + commentId, + providerMessageId: "review-message", + direction: "inbound", + }); + return { binding, context, companyId, agentId, issueId, otherIssueId, runId, + endpointId, resourceId, conversationId, principalId, commentId, deliveryId, userId }; +} diff --git a/server/src/__tests__/prepared-chat-authorization.integration.test.ts b/server/src/__tests__/prepared-chat-authorization.integration.test.ts new file mode 100644 index 00000000000..62a20966fda --- /dev/null +++ b/server/src/__tests__/prepared-chat-authorization.integration.test.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createDb, issueComments, chatEndpoints, chatDeliveries, chatMessageLinks, type Db } from "@paperclipai/db"; +import { seedBoundChatAuthorization } from "./helpers/bound-chat-authorization.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { authorizeChatConversationForBoundRun, isExternalChatWaitAuthorizationContention } + from "../services/native-runtime/chat-attachment-reuse.js"; + +describe("prepared bound-chat authorization", () => { + let temporary: Awaited>; + let db: Db; + let fixture: Awaited>; + let binding: typeof fixture.binding, context: typeof fixture.context; + let companyId: string, issueId: string, otherIssueId: string, endpointId: string, + conversationId: string, principalId: string, commentId: string, deliveryId: string, userId: string; + const authorize = (handle: Db = db, ids = [commentId]) => + authorizeChatConversationForBoundRun(handle, binding, { ...context, wakeCommentIds: ids }, "read"); + const denied = "paperclip_runner_chat_attachment_binding_denied"; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("prepared-chat-"); + db = createDb(temporary.connectionString); + fixture = await seedBoundChatAuthorization(db); + ({ binding, context, companyId, issueId, otherIssueId, endpointId, conversationId, + principalId, commentId, deliveryId, userId } = fixture); + }); + afterAll(async () => { + try { await db?.$client.end({ timeout: 1 }); } + finally { await temporary?.cleanup(); } + }); + + it("binds changed values and array arities while reading current authorization", async () => { + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + await expect(authorize(db, [commentId, commentId])).resolves.toEqual({ endpointId, conversationId }); + const secondCommentId = randomUUID(), secondDeliveryId = randomUUID(); + await db.insert(issueComments).values({ id: secondCommentId, companyId, issueId, + authorType: "user", authorUserId: userId, body: "Another message" }); + await db.insert(chatDeliveries).values({ id: secondDeliveryId, companyId, endpointId, + conversationId, principalId, providerEventId: secondDeliveryId, deduplicationKey: secondDeliveryId, + eventKind: "message", normalizedEvent: {}, state: "processed", processedAt: new Date() }); + await db.insert(chatMessageLinks).values({ companyId, endpointId, conversationId, + deliveryId: secondDeliveryId, commentId: secondCommentId, providerMessageId: secondCommentId, direction: "inbound" }); + await expect(authorize(db, [secondCommentId])).resolves.toEqual({ endpointId, conversationId }); + await expect(authorize(db, [secondCommentId, commentId])).resolves.toEqual({ endpointId, conversationId }); + await expect(authorize(db, [commentId, randomUUID()])).rejects.toThrow(denied); + await expect(authorize(db, [])).rejects.toThrow(denied); + for (const changed of [{ companyId: randomUUID() }, { issueId: otherIssueId }, + { agentId: randomUUID() }]) { + await expect(authorizeChatConversationForBoundRun(db, { ...binding, ...changed }, context, "read")) + .rejects.toThrow(denied); + } + await expect(authorizeChatConversationForBoundRun(db, binding, { ...context, source: "chat:discord" }, "read")) + .rejects.toThrow(denied); + await expect(authorize(db, ["invalid-uuid"])).rejects.toThrow(); + await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, endpointId)); + await expect(authorize()).rejects.toThrow(denied); + await db.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpointId)); + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + }); + + it("rechecks setup generation and credential fences on a reused template", async () => { + const startedAt = new Date("2026-11-01T00:00:00.000Z"); + await db.update(chatEndpoints).set({ status: "verifying", setup: sql`${JSON.stringify({ + step: "test", testStartedAt: startedAt.toISOString(), runtimeGeneration: 7, + })}::jsonb` }).where(eq(chatEndpoints.id, endpointId)); + try { + await expect(authorize()).rejects.toThrow(denied); + await db.update(chatDeliveries).set({ + receivedAt: new Date(startedAt.getTime() + 1_000), + processedAt: new Date(startedAt.getTime() + 2_000), + normalizedEvent: { runtimeContext: { + generation: 7, credentialFingerprint: "a".repeat(64), + } }, + }).where(eq(chatDeliveries.id, deliveryId)); + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + await db.update(chatDeliveries).set({ normalizedEvent: { runtimeContext: { + generation: 8, credentialFingerprint: "a".repeat(64), + } } }).where(eq(chatDeliveries.id, deliveryId)); + await expect(authorize()).rejects.toThrow(denied); + await db.update(chatDeliveries).set({ normalizedEvent: { runtimeContext: { + generation: 7, credentialFingerprint: "malformed", + } } }).where(eq(chatDeliveries.id, deliveryId)); + await expect(authorize()).rejects.toThrow(denied); + } finally { + await db.update(chatEndpoints).set({ status: "active", setup: sql`'{}'::jsonb` }).where(eq(chatEndpoints.id, endpointId)); + } + }); + + it("keeps transaction and savepoint sessions separate from a warmed root template", async () => { + await authorize(); + await expect(db.transaction(async (tx) => { + await tx.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, endpointId)); + await expect(authorize(tx as unknown as Db)).rejects.toThrow(denied); + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + await expect(tx.transaction(async (savepoint) => { + await savepoint.update(chatEndpoints).set({ status: "active" }).where(eq(chatEndpoints.id, endpointId)); + await expect(authorize(savepoint as unknown as Db)).resolves.toEqual({ endpointId, conversationId }); + throw new Error("rollback-savepoint"); + })).rejects.toThrow("rollback-savepoint"); + await expect(authorize(tx as unknown as Db)).rejects.toThrow(denied); + throw new Error("rollback-outer"); + })).rejects.toThrow("rollback-outer"); + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + await db.transaction(async (tx) => { + await expect(authorize(tx as unknown as Db)).resolves.toEqual({ endpointId, conversationId }); + }); + }); + + it("executes every lock mode on the same transaction handle and PostgreSQL session", async () => { + await db.transaction(async (tx) => { + for (const mode of ["read", "blocking", "nonblocking", "read"] as const) { + await expect(authorizeChatConversationForBoundRun(tx as unknown as Db, binding, context, mode)) + .resolves.toEqual({ endpointId, conversationId }); + } + }); + }); + + it("preserves blocking row locks and NOWAIT contention after read templates are warm", async () => { + await authorize(); + let release!: () => void; + let acquired!: () => void; + const acquiredPromise = new Promise((resolve) => { acquired = resolve; }); + const held = new Promise((resolve) => { release = resolve; }); + const locker = db.transaction(async (tx) => { + await tx.select({ id: chatMessageLinks.id }).from(chatMessageLinks) + .where(eq(chatMessageLinks.commentId, commentId)).for("update"); + acquired(); + await held; + }); + try { + await acquiredPromise; + await expect(authorize()).resolves.toEqual({ endpointId, conversationId }); + let contention: unknown; + try { await db.transaction((tx) => authorizeChatConversationForBoundRun(tx as unknown as Db, binding, context, "nonblocking")); } + catch (error) { contention = error; } + expect(isExternalChatWaitAuthorizationContention(contention)).toBe(true); + await expect(db.transaction(async (tx) => { + await tx.execute(sql`set local lock_timeout = '100ms'`); + await authorizeChatConversationForBoundRun(tx as unknown as Db, binding, context, "blocking"); + })).rejects.toThrow(); + } finally { release(); await locker; } + await db.transaction(async (tx) => { + await expect(authorizeChatConversationForBoundRun(tx as unknown as Db, binding, context, "nonblocking")) + .resolves.toEqual({ endpointId, conversationId }); + }); + }); +}); diff --git a/server/src/services/native-runtime/chat-attachment-reuse.ts b/server/src/services/native-runtime/chat-attachment-reuse.ts index 6cc44299ee7..dfc7343168b 100644 --- a/server/src/services/native-runtime/chat-attachment-reuse.ts +++ b/server/src/services/native-runtime/chat-attachment-reuse.ts @@ -456,30 +456,7 @@ async function principalAuthorized( return endpoint.sponsorUserId ? activeMember(endpoint.sponsorUserId) : true; } -/** Caller must independently verify the current run/issue execution owner. */ -export async function authorizeChatConversationForBoundRun( - tx: Db, - binding: ChatReuseBinding, - contextSnapshot: unknown, - lockMode: AuthorizationLockMode = "blocking", -): Promise { - let context = record(contextSnapshot); - if (context.source === "issue.interaction.respond") { - const answer = await resolveExternalChatQuestionResponse(tx, binding, context, lockMode); - if (!answer) throw new Error("paperclip_runner_chat_attachment_binding_denied"); - context = answer.authorizationContext; - } - const source = typeof context.source === "string" ? context.source : ""; - const provider = boundExternalChatProvider(source); - const commentIds = wakeCommentIds(context); - if ( - !provider || - (context.paperclipHarnessCheckedOut !== true && - context.paperclipExternalChatExecutionBound !== true) || - commentIds.length === 0 - ) { - throw new Error("paperclip_runner_chat_attachment_binding_denied"); - } +function prepareBoundRunLinks(tx: Db, lockMode: AuthorizationLockMode) { const linksQuery = tx .select({ commentId: chatMessageLinks.commentId, @@ -539,25 +516,69 @@ export async function authorizeChatConversationForBoundRun( ) .where( and( - eq(chatMessageLinks.companyId, binding.companyId), + eq(chatMessageLinks.companyId, sql.placeholder("companyId")), eq(chatMessageLinks.direction, "inbound"), - inArray(chatMessageLinks.commentId, commentIds), + sql`${chatMessageLinks.commentId} = any(${sql.placeholder("commentIds")}::uuid[])`, eq(chatDeliveries.state, "processed"), - eq(chatConversations.issueId, binding.issueId), + eq(chatConversations.issueId, sql.placeholder("issueId")), inArray(chatConversations.state, ["active", "waiting"]), - eq( - chatEndpoints.provider, - provider as typeof chatEndpoints.$inferSelect.provider, - ), - eq(chatEndpoints.assignedAgentId, binding.agentId), + eq(chatEndpoints.provider, sql.placeholder("provider")), + eq(chatEndpoints.assignedAgentId, sql.placeholder("agentId")), inArray(chatEndpoints.status, ["active", "verifying"]), ), ); - const links = await (lockMode === "read" + return (lockMode === "read" ? linksQuery : lockMode === "nonblocking" ? linksQuery.for("update", { noWait: true }) - : linksQuery.for("update")); + : linksQuery.for("update")).prepare("chat_bound_run_links"); +} + +const boundRunLinksTemplates = new WeakMap< + Db, + Partial>> +>(); + +function boundRunLinksTemplate(tx: Db, lockMode: AuthorizationLockMode) { + let templates = boundRunLinksTemplates.get(tx); + if (!templates) { + templates = {}; + boundRunLinksTemplates.set(tx, templates); + } + return templates[lockMode] ??= prepareBoundRunLinks(tx, lockMode); +} + +/** Caller must independently verify the current run/issue execution owner. */ +export async function authorizeChatConversationForBoundRun( + tx: Db, + binding: ChatReuseBinding, + contextSnapshot: unknown, + lockMode: AuthorizationLockMode = "blocking", +): Promise { + let context = record(contextSnapshot); + if (context.source === "issue.interaction.respond") { + const answer = await resolveExternalChatQuestionResponse(tx, binding, context, lockMode); + if (!answer) throw new Error("paperclip_runner_chat_attachment_binding_denied"); + context = answer.authorizationContext; + } + const source = typeof context.source === "string" ? context.source : ""; + const provider = boundExternalChatProvider(source); + const commentIds = wakeCommentIds(context); + if ( + !provider || + (context.paperclipHarnessCheckedOut !== true && + context.paperclipExternalChatExecutionBound !== true) || + commentIds.length === 0 + ) { + throw new Error("paperclip_runner_chat_attachment_binding_denied"); + } + const links = await boundRunLinksTemplate(tx, lockMode).execute({ + companyId: binding.companyId, + issueId: binding.issueId, + agentId: binding.agentId, + provider, + commentIds, + }); const linkedCommentIds = new Set(links.map((row) => row.commentId)); const conversationIds = new Set(links.map((row) => row.conversation.id)); const endpointIds = new Set(links.map((row) => row.endpoint.id));