import { and, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { heartbeatRuns, issueThreadInteractions } from "@paperclipai/db"; import type { AskUserQuestionsInteraction, RespondIssueThreadInteraction, } from "@paperclipai/shared"; import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared"; import type { PrpEvent } from "../../vendor/paperclip-runner/index.js"; import { parsePaperclipQuestionSet, type PaperclipQuestionSet, } from "../../vendor/paperclip-runner/index.js"; import { logger } from "../../middleware/logger.js"; import { unprocessable } from "../../errors.js"; import { logActivity } from "../activity-log.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { questionResponseDeliveryService } from "../question-response-delivery.js"; import type { NativeRunStoreBinding } from "./native-run-coordinator-store.js"; import { parseQuestionInteractionAnswers, parseSavedQuestionInteractionAnswers } from "../question-interaction-answers.js"; const QUESTION_KEY_PREFIX = "paperclip-runner-question:"; const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/; export const NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY = "nativeQuestionCancellation"; type QueueCommand = ( type: string, payload?: Record, commandId?: string, ) => { readonly commandId: string; readonly controllerSeq: number } | Promise<{ readonly commandId: string; readonly controllerSeq: number }>; interface NativeQuestionCommandTarget { binding: Pick; queueCommand: QueueCommand; } const activeTargets = new Map(); interface NativeQuestionIdentity { idempotencyKey?: string | null; sourceRunId?: string | null; payload: unknown; } export interface NativeQuestionAuthorizationIdentity extends NativeQuestionIdentity { companyId: string; issueId: string; } export type NativeQuestionCancellationCause = | { kind: "issue_terminal"; issueStatus: string } | { kind: "interaction_withdrawn"; interactionId: string } | { kind: "interaction_cancelled"; interactionId: string }; type DbTransaction = Parameters[0]>[0]; type NativeQuestionMutationDb = Pick; function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function requestIdForInteraction( interaction: NativeQuestionIdentity, ): string | null { const payload = record(interaction.payload); if (!interaction.sourceRunId || !payload?.questionSet) return null; const key = interaction.idempotencyKey; const expectedPrefix = `${QUESTION_KEY_PREFIX}${interaction.sourceRunId}:`; if (!key?.startsWith(expectedPrefix)) return null; const requestId = key.slice(expectedPrefix.length); if (!REQUEST_ID_PATTERN.test(requestId)) return null; return typeof payload.runtimeRequestId === "string" && payload.runtimeRequestId !== requestId ? null : requestId; } function toInteractionPayload(questionSet: PaperclipQuestionSet, runtimeRequestId: string) { return { ...questionSetToAskUserQuestionsPayload(questionSet), runtimeRequestId, // A generic task comment cannot satisfy this provider request. supersedeOnUserComment: false, }; } async function authorizedNativeRun( db: Pick, interaction: NativeQuestionAuthorizationIdentity, ) { const requestId = requestIdForInteraction(interaction); if (!requestId || !interaction.sourceRunId) return null; const run = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, issueId: heartbeatRuns.nativeIssueId, agentId: heartbeatRuns.agentId, runtimeMode: heartbeatRuns.runtimeMode, status: heartbeatRuns.status, }).from(heartbeatRuns).where(and( eq(heartbeatRuns.id, interaction.sourceRunId), eq(heartbeatRuns.companyId, interaction.companyId), eq(heartbeatRuns.nativeIssueId, interaction.issueId), eq(heartbeatRuns.runtimeMode, "native"), )).limit(1).then((rows) => rows[0] ?? null); return run ? { ...run, requestId } : null; } /** Materialize questions; permission requests use the privileged runtime card. */ export async function projectNativeRuntimeRequest(input: { db: Db; binding: Pick; event: PrpEvent; }): Promise { if (input.event.eventType !== "runtime_request.created") return null; if ( input.event.runId !== input.binding.runId || input.event.normalizedSessionId !== input.binding.normalizedSessionId || input.event.sourceInstanceId !== input.binding.runnerSourceInstanceId ) { throw new Error("native_runtime_request_binding_mismatch"); } const request = record(record(input.event.payload)?.request); if ( !request || request.schema !== "paperclip.runtime_request.v2" || request.status !== "pending" || typeof request.requestId !== "string" || !REQUEST_ID_PATTERN.test(request.requestId) ) { throw new Error("native_runtime_request_invalid"); } if (request.requestKind === "permission_approval" && request.type === "permission") { const choices = Array.isArray(request.choices) ? request.choices.map(record) : []; const supported = new Set(["accept", "accept_for_session", "decline", "cancel"]); if ( typeof request.turnId !== "string" || request.turnId !== input.event.turnId || (request.itemId != null && typeof request.itemId !== "string") || (request.itemId ?? null) !== (input.event.itemId ?? null) || typeof request.prompt !== "string" || !request.prompt.trim() || request.prompt.length > 4000 || choices.length === 0 || choices.length > 4 || choices.some((choice) => !choice || typeof choice.key !== "string" || !supported.has(choice.key) || typeof choice.label !== "string" || !choice.label.trim() || choice.label.length > 500) || new Set(choices.map((choice) => choice?.key)).size !== choices.length || (request.details !== undefined && !record(request.details)) ) { throw new Error("native_runtime_permission_invalid"); } // The committed run event itself feeds TaskChatProtocolCard. Its decisions // go through the instance-admin runtime-request route and the exact pending // turn, not the human-only question-response delivery path. Returning here // allows the durable coordinator to acknowledge the event without creating // a second, less-privileged interaction or changing any offered choices. return null; } if (request.requestKind !== "runtime" || request.type !== "input") { throw new Error("native_runtime_request_invalid"); } const questionSet = parsePaperclipQuestionSet(request.input); if (questionSet.questions.some((question) => question.textValidation?.pattern !== undefined)) { // JavaScript regular expressions have no execution budget. Provider-authored // patterns therefore stay fail-closed until the runner contract supplies a // bounded regex dialect rather than exposing the server to catastrophic backtracking. throw new Error("native_runtime_question_pattern_unsupported"); } const idempotencyKey = `${QUESTION_KEY_PREFIX}${input.binding.runId}:${request.requestId}`; const existing = await input.db.select({ id: issueThreadInteractions.id }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, input.binding.companyId), eq(issueThreadInteractions.issueId, input.binding.issueId), eq(issueThreadInteractions.idempotencyKey, idempotencyKey), )) .limit(1) .then((rows) => rows[0] ?? null); const interaction = await issueThreadInteractionService(input.db).create( { id: input.binding.issueId, companyId: input.binding.companyId }, { kind: "ask_user_questions", idempotencyKey, sourceRunId: input.binding.runId, resolverPolicy: "human_only", continuationPolicy: "none", ...(questionSet.title ? { title: questionSet.title.slice(0, 240) } : {}), ...(typeof request.prompt === "string" ? { summary: request.prompt.slice(0, 1000) } : {}), payload: toInteractionPayload(questionSet, request.requestId), }, { agentId: input.binding.agentId, runId: input.binding.runId }, { supersedePendingSiblingInteractions: false }, ) as AskUserQuestionsInteraction; if (!existing) { await logActivity(input.db, { companyId: input.binding.companyId, actorType: "agent", actorId: input.binding.agentId, agentId: input.binding.agentId, runId: input.binding.runId, action: "issue.thread_interaction_created", entityType: "issue", entityId: input.binding.issueId, details: { interactionId: interaction.id, interactionKind: interaction.kind, interactionStatus: interaction.status, runtimeMode: "native", }, }); } if (interaction.status === "answered") { await deliverNativeQuestionResponseDurably(input.db, interaction); } return interaction; } /** Validate untrusted board input before the existing interaction service persists it. */ export async function validateNativeQuestionResponseInput( interaction: AskUserQuestionsInteraction, input: RespondIssueThreadInteraction, ): Promise { if (!requestIdForInteraction(interaction) || !interaction.payload.questionSet) return; try { await parseQuestionInteractionAnswers(interaction.payload.questionSet, input.answers, interaction.payload.questions); } catch (error) { throw unprocessable( error instanceof Error ? error.message : "Invalid native question response", { code: "invalid_question_response" }, ); } } /** Queue an answered interaction into the active durable PRP command stream. */ export async function deliverNativeQuestionResponse( db: Db, interaction: AskUserQuestionsInteraction, ): Promise<"not_native" | "pending" | "queued"> { if (interaction.status !== "answered" || !interaction.result || !interaction.payload.questionSet) { return "not_native"; } const run = await authorizedNativeRun(db, interaction); // A historical question can be answered after its provider turn has ended. // Fall through to durable fresh-wake delivery instead of waiting forever for // a command target that cannot return for this terminal run. if (!run || ["succeeded", "failed", "cancelled", "timed_out"].includes(run.status)) return "not_native"; const response = parseSavedQuestionInteractionAnswers(interaction.payload.questionSet, interaction.result.answers, interaction.payload.questions); const target = activeTargets.get(run.id); if ( !target || target.binding.companyId !== run.companyId || target.binding.issueId !== run.issueId || target.binding.agentId !== run.agentId ) { return "pending"; } try { await target.queueCommand( "request.resolve", { requestId: run.requestId, response: response as unknown as Record }, `question_${interaction.id}`, ); return "queued"; } catch (error) { logger.warn( { err: error, runId: run.id, interactionId: interaction.id }, "native question response remains durable for session recovery", ); return "pending"; } } async function deliverNativeQuestionResponseDurably( db: Db, interaction: AskUserQuestionsInteraction, ): Promise { await questionResponseDeliveryService(db, { heartbeat: { wakeup: async () => { throw new Error("native_question_wake_unreachable"); }, } as never, resolveNativeQuestion: (candidate) => deliverNativeQuestionResponse(db, candidate), }).deliver(interaction.id); } export async function flushNativeQuestionResponses( db: Db, runId: string, ): Promise { const target = activeTargets.get(runId); if (!target) return; const interactions = await issueThreadInteractionService(db).listForIssue(target.binding.issueId); for (const interaction of interactions) { if ( interaction.kind === "ask_user_questions" && interaction.sourceRunId === runId && interaction.status === "answered" ) { await deliverNativeQuestionResponseDurably(db, interaction); } } } export function registerNativeQuestionCommandTarget(target: NativeQuestionCommandTarget): () => void { const existing = activeTargets.get(target.binding.runId); if (existing) throw new Error("native_question_command_target_conflict"); activeTargets.set(target.binding.runId, target); return () => { if (activeTargets.get(target.binding.runId) === target) { activeTargets.delete(target.binding.runId); } }; } export async function nativeQuestionRunToCancel( db: Db, interaction: NativeQuestionAuthorizationIdentity, ): Promise { const run = await authorizedNativeRun(db, interaction); return run && ["queued", "running"].includes(run.status) ? run.id : null; } /** * Persist cancellation intent in the same transaction that closes the issue. * The post-commit fast path and the heartbeat recovery sweep both consume this * marker, so process exit or a transient process-termination failure cannot * strand a native run after the task closes, even if its question is retained. */ export async function requestNativeQuestionRunCancellation( db: NativeQuestionMutationDb, interaction: NativeQuestionAuthorizationIdentity, cause: NativeQuestionCancellationCause, ): Promise { const run = await authorizedNativeRun(db, interaction); if (!run || !["queued", "running"].includes(run.status)) return null; const marker = JSON.stringify({ version: 1, issueId: interaction.issueId, ...cause, requestedAt: new Date().toISOString(), }); return db.update(heartbeatRuns).set({ contextSnapshot: sql`jsonb_set( case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object' then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end, array[${NATIVE_QUESTION_CANCELLATION_CONTEXT_KEY}], ${marker}::jsonb, true )`, updatedAt: new Date(), }).where(and( eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, interaction.companyId), eq(heartbeatRuns.nativeIssueId, interaction.issueId), eq(heartbeatRuns.runtimeMode, "native"), inArray(heartbeatRuns.status, ["queued", "running"]), )).returning({ id: heartbeatRuns.id }).then((rows) => rows[0]?.id ?? null); } /** Capture the minimum bound identity needed to cancel after the issue transaction commits. */ export function nativeQuestionCancellationIdentity( interaction: NativeQuestionAuthorizationIdentity, ): NativeQuestionAuthorizationIdentity | null { if (!requestIdForInteraction(interaction)) return null; return { companyId: interaction.companyId, issueId: interaction.issueId, sourceRunId: interaction.sourceRunId, payload: interaction.payload, idempotencyKey: interaction.idempotencyKey, }; } export const nativeQuestionBridgeInternals = { resetForTests: () => activeTargets.clear(), };