mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
import { notifyLiveRunRecovery } from "../live-run-recovery-notifications.js";
|
|
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<string, unknown>,
|
|
commandId?: string,
|
|
) => { readonly commandId: string; readonly controllerSeq: number } | Promise<{ readonly commandId: string; readonly controllerSeq: number }>;
|
|
|
|
interface NativeQuestionCommandTarget {
|
|
binding: Pick<NativeRunStoreBinding, "companyId" | "issueId" | "runId" | "agentId">;
|
|
queueCommand: QueueCommand;
|
|
}
|
|
|
|
const activeTargets = new Map<string, NativeQuestionCommandTarget>();
|
|
|
|
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<Parameters<Db["transaction"]>[0]>[0];
|
|
type NativeQuestionMutationDb = Pick<Db | DbTransaction, "select" | "update">;
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: 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<Db | DbTransaction, "select">,
|
|
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<NativeRunStoreBinding, "companyId" | "issueId" | "runId" | "agentId" | "normalizedSessionId" | "runnerSourceInstanceId">;
|
|
event: PrpEvent;
|
|
}): Promise<AskUserQuestionsInteraction | null> {
|
|
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<void> {
|
|
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<string, unknown> },
|
|
`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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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(),
|
|
});
|
|
await notifyLiveRunRecovery(db, { companyId: interaction.companyId, runId: run.id });
|
|
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(),
|
|
};
|