mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/204/changes/paperclip/204 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip coordinates agent work and human approvals. > - Human decisions already persist the continuation action that resumes an agent. > - A response can commit before its callback or publication finishes. > - Periodic maintenance repeatedly discovers those rows, including when idle. > - Committed writes should wake the existing owner; saved retries and claims supply real deadlines. > - Startup reconstructs durable work, and settled history becomes quiet. ## Linked Issues or Issue Description Refs #17651, which is merged. This existing continuation slice targets master and removes its duty from periodic chat maintenance. **Problem:** A saved `interaction_wakeup` action can survive a committed human response whose callback crashes. Without producer coverage, removing maintenance strands the continuation. Repeated empty maintenance also keeps the database active. **Expected behavior:** The outer transaction's settlement wakes the continuation owner. It handles ready conversation publications, dispatches under existing authority, preserves interrupted/retry work, and becomes quiet after settlement. ## What Changed - Register continuation recovery with the shared scoped deadline coordinator. Reconstruct existing issued/processing actions and retain the existing retry and stale-claim deadlines. - Notify from both enqueue producers and owner-local claim/settlement writes. Preserve outer commit/rollback and avoid moving provider or heartbeat calls into database transactions. - Give the existing publication queue an optional company/endpoint/conversation scope. The continuation owner gives ready cards in that conversation a bounded delivery opportunity before claiming the action; FIFO, capacity, ambiguity and provider authority remain with the queue. - Carry admission/cancellation through publication preparation and check before every new send. Join in-flight requests and retain pending actions/publications through drain, standby, shutdown and resume. - Remove only recurring continuation recovery; keep the manual API. Add no table. A concurrent partial index on existing pending continuation actions and matching company/id keyset paging prevent retained history from being scanned. Parent #17651's indexed confirmation discovery and current master are retained. ## Verification Published head: `a1d16f84b3fae361d5d648670a5964486370c3a3`, on actual landed terminal-confirmation parent `ee67d2ece9bda157dfad9bdf25175b1d004ed341`. - **75 distinct current cases pass:** 32 selected real PostgreSQL chat cases (14 continuation lifecycle/producer/stop cases, one new continuation query-cost case, and 17 parent confirmation/query-cost cases; 1,671 skipped), and all 43 cases in complete interaction-publication and app/startup suites. Database build, server typecheck/build, module boundaries and fresh migrations through 0399 pass. The original landed-parent matrix passed 74 cases before the additional query-cost repair; those are not added again. - The new real-SQL regression seeds 20,000 completed/unrelated actions and one pending processing claim. Before repair it examines 20,345 rows and hits 20,421 buffers. The final startup and company-scoped queries must examine at most two action rows and fewer than 20 action buffers; empty scoped recovery must also stay below 20 buffers. Planner settings are unchanged. Migration `0399_ambitious_onslaught.sql` is concurrent/idempotent and uses the existing top-level migration runner; prior SQL/journal entries and the five newest snapshots are preserved. - The matrix covers callback recovery, concurrent responses, malformed prefixes, more than one page, saved retries/stale claims, outer rollback/commit, deferred/future/paused publication states, restart, drain and stop before each new provider request. Quiet assertions inspect real selects/transactions across virtual idle time. - Normal integration `db5a84c0f8b73a0ce863f4d2fbaee0a115cf044f` preserves the complete 12-path owned binary patch and all 12 unrelated current-master paths, including managed Cloud sandbox changes. A verified semantic base resolves squash-ancestry conflicts; both normal parents and the exact tree are checked. The subsequent index/keyset repair is a deliberate additional change, tested separately; the final patch is not claimed identical to the original 12-file implementation. No force push or rebase occurs. - The same 74 pre-index cases passed on the earlier prepared and actual landed parents. The new query-cost regression failed before its repair, then passes on the repaired source. Earlier 57-case and broad acceptance retain their original revision scopes; repeated cases are not added to the current count. The audit-only follow-up leaves all owned server source/test hashes unchanged. - [The owner audit](https://github.com/paperclipai/paperclip/blob/a1d16f84b3fae361d5d648670a5964486370c3a3/doc/plans/2026-10-25-interaction-continuation-sleep-audit.md#acceptance-on-the-actual-landed-terminal-confirmation-parent) retains the original missing-signal regression, the skipped standalone publication suite, and the review's stop-before-send failures with their corrections. Skips and earlier failing revisions are not relabelled as current passes. - **Fresh exact-head CI and Greptile 5/5 without unresolved actionable findings remain required.** The former remote head's reviews are stale. No full local repository suite, live provider or deployed sleep result is claimed. ## Risks - Process-local transaction hints cover supported application writers and shared pools. Independent SQL writers and other replicas do not gain a new wake guarantee. - The publication queue's existing FIFO, capacity, retry/ambiguity and authorization contracts still govern effects. In-flight provider requests can settle after Stop; the fence prevents a subsequent request from starting. - Missing a supported producer can defer recovery until another change or startup. Producer and rollback tests cover this slice's supported paths. - Worker quiet is separate from host/database suspension. External alarm coverage and Cloud sleep gates are unchanged. ## Model Used OpenAI Codex, using repository inspection, code execution and real PostgreSQL tests. The exact model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
861 lines
36 KiB
TypeScript
861 lines
36 KiB
TypeScript
import { notifyChatContinuationWork } from "./chat-continuation-notifications.js";
|
|
import { chatInteractionForm } from "./chat-interaction-forms.js";
|
|
import { slackInteractionRequiresBoard } from "./chat-channels/slack/cards.js";
|
|
import { answeredChatQuestionBody, chatInteractionPresentation } from "./chat-interaction-presentation.js";
|
|
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
|
|
import { nativePhotonInteraction } from "./photon/interactions.js";
|
|
import { randomBytes } from "node:crypto";
|
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
chatActions,
|
|
chatConversations,
|
|
chatEndpoints,
|
|
chatPublications,
|
|
heartbeatRuns,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AskUserQuestionsInteraction,
|
|
AskUserQuestionsQuestion,
|
|
IssueThreadInteraction,
|
|
RequestConfirmationInteraction,
|
|
RequestCheckboxConfirmationInteraction,
|
|
SuggestTasksInteraction,
|
|
SafeExternalChatCardAction,
|
|
} from "@paperclipai/shared";
|
|
import { projectSafeChatPublication } from "./chat-publication-projection.js";
|
|
import { publicChatTaskUrl } from "./chat-task-url.js";
|
|
import {
|
|
chatQuestionFormActionRecords,
|
|
createChatQuestionFormDraft,
|
|
} from "./chat-question-forms.js";
|
|
|
|
const MAX_NATIVE_QUESTION_OPTIONS = 12;
|
|
const QUESTION_ACTION_PREFIX = "pcq:";
|
|
const QUESTION_ACTION_TOKEN_BYTES = 16;
|
|
export const CHAT_QUESTION_ACTION_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
export const TELEGRAM_CALLBACK_DATA_LIMIT_BYTES = 64;
|
|
|
|
type ChatPublicationDb = Pick<Db, "select" | "insert" | "update" | "transaction">;
|
|
|
|
function terminalNativeInteractionCopy(
|
|
interaction: IssueThreadInteraction,
|
|
): { body: string; text: string } | null {
|
|
if (interaction.kind === "request_confirmation") {
|
|
if (interaction.status === "accepted")
|
|
return { body: "Accepted", text: "Accepted." };
|
|
if (interaction.status === "rejected")
|
|
return { body: "Rejected", text: "Rejected." };
|
|
if (interaction.status === "cancelled") {
|
|
const outcome = interaction.result?.outcome;
|
|
const body =
|
|
outcome === "skipped"
|
|
? "Skipped in Paperclip"
|
|
: outcome === "withdrawn"
|
|
? "Withdrawn in Paperclip"
|
|
: outcome === "addressee_deleted"
|
|
? "Cancelled: addressed agent was removed"
|
|
: "Cancelled in Paperclip";
|
|
return { body, text: `${body}.` };
|
|
}
|
|
if (interaction.status === "expired") {
|
|
const body =
|
|
interaction.result?.outcome === "superseded_by_comment"
|
|
? "Expired: superseded by a newer reply"
|
|
: interaction.result?.outcome === "superseded_by_newer_request"
|
|
? "Expired: replaced by a newer request"
|
|
: interaction.result?.outcome === "stale_target"
|
|
? "Expired: target is no longer current"
|
|
: interaction.result?.outcome === "issue_closed"
|
|
? "Expired: task is closed"
|
|
: "Expired in Paperclip";
|
|
return { body, text: `${body}.` };
|
|
}
|
|
return null;
|
|
}
|
|
if (interaction.kind === "request_checkbox_confirmation" && interaction.status === "accepted") {
|
|
const labels = interaction.payload.options.filter(option => interaction.result?.selectedOptionIds?.includes(option.id)).map(option => option.label);
|
|
const body = labels.length ? `Saved checks: ${labels.join("; ")}` : "Saved checks: none selected.";
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.kind === "request_item_verdicts" && interaction.status === "answered") {
|
|
const body = interaction.payload.items.map(item => {
|
|
const result = interaction.result?.items.find(result => result.id === item.id);
|
|
return `${item.label}: ${result?.verdict ?? "pending"}${result?.reason ? ` — ${result.reason}` : ""}`;
|
|
}).join("\n\n");
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.kind === "suggest_tasks" && (interaction.status === "accepted" || interaction.status === "rejected")) {
|
|
const body = interaction.status === "rejected" ? "Dismissed — no tasks created." :
|
|
(interaction.result?.createdTasks ?? []).map(task => {
|
|
const url = publicChatTaskUrl(task.issueId);
|
|
return `Created: ${task.identifier ?? "Task"} — ${task.title}${url ? `\n${url}` : ""}`;
|
|
}).join("\n\n");
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.kind === "connection_intent" && (interaction.status === "accepted" || interaction.status === "rejected")) {
|
|
const body = interaction.status === "accepted" ? `Connected: ${interaction.payload.serviceName}` : `Declined: ${interaction.payload.serviceName}. No access was granted.`;
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.kind !== "ask_user_questions") {
|
|
const body = ({ accepted: "Accepted", rejected: "Rejected", answered: "Answered", cancelled: "Cancelled in Paperclip", expired: "Expired in Paperclip" } as Record<string, string>)[interaction.status];
|
|
return body ? { body, text: `${body}.` } : null;
|
|
}
|
|
if (interaction.status === "answered") {
|
|
const question = nativeChatQuestion(interaction);
|
|
const answer = question
|
|
? interaction.result?.answers.find(
|
|
(candidate) => candidate.questionId === question.id,
|
|
)
|
|
: null;
|
|
const option =
|
|
answer?.optionIds.length === 1 && !answer.otherText
|
|
? question?.options.find(
|
|
(candidate) => candidate.id === answer.optionIds[0],
|
|
)
|
|
: null;
|
|
const body = option ? `Answered: ${option.label}.` : answeredChatQuestionBody(interaction);
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.status === "cancelled") {
|
|
const outcome = interaction.result?.outcome;
|
|
const body =
|
|
outcome === "skipped"
|
|
? "Skipped in Paperclip."
|
|
: outcome === "withdrawn"
|
|
? "Withdrawn in Paperclip."
|
|
: "Cancelled in Paperclip.";
|
|
return { body, text: body };
|
|
}
|
|
if (interaction.status === "expired") {
|
|
const body =
|
|
interaction.result?.expirationReason === "superseded_by_comment"
|
|
? "Expired: superseded by a newer reply"
|
|
: interaction.result?.expirationReason ===
|
|
"superseded_by_newer_interaction"
|
|
? "Expired: replaced by a newer request"
|
|
: interaction.result?.outcome === "issue_closed"
|
|
? "Expired: task is closed"
|
|
: "Expired in Paperclip";
|
|
return { body, text: `${body}.` };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const publicChatInteractionTaskUrl = publicChatTaskUrl;
|
|
|
|
/**
|
|
* Provider callbacks carry this compact, cryptographically random action id.
|
|
* Its durable chat_actions row is the only mapping back to an interaction,
|
|
* question, and option. Keeping the token opaque avoids exposing canonical ids
|
|
* and fits Telegram's strict 64-byte callback_data envelope.
|
|
*/
|
|
export function createChatQuestionOptionActionToken(): string {
|
|
return `${QUESTION_ACTION_PREFIX}${randomBytes(QUESTION_ACTION_TOKEN_BYTES).toString("base64url")}`;
|
|
}
|
|
|
|
export function createChatConfirmationActionToken(): string {
|
|
return createChatQuestionOptionActionToken();
|
|
}
|
|
|
|
/** Mirrors the pinned Chat SDK Telegram adapter's exact wire envelope. */
|
|
export function telegramChatSdkCallbackData(
|
|
actionId: string,
|
|
value?: string,
|
|
): string {
|
|
return `chat:${JSON.stringify({
|
|
a: actionId,
|
|
...(typeof value === "string" ? { v: value } : {}),
|
|
})}`;
|
|
}
|
|
|
|
export function telegramCallbackDataByteLength(
|
|
actionId: string,
|
|
value?: string,
|
|
): number {
|
|
return Buffer.byteLength(
|
|
telegramChatSdkCallbackData(actionId, value),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
export function nativeChatQuestion(
|
|
interaction: AskUserQuestionsInteraction,
|
|
maxOptions = MAX_NATIVE_QUESTION_OPTIONS,
|
|
): AskUserQuestionsQuestion | null {
|
|
if (interaction.payload.questions.length !== 1) return null;
|
|
const question = interaction.payload.questions[0];
|
|
if (
|
|
question.selectionMode !== "single" ||
|
|
question.allowOther === true ||
|
|
question.options.length > maxOptions ||
|
|
question.options.some((option) => option.freeText === true)
|
|
) {
|
|
return null;
|
|
}
|
|
return question;
|
|
}
|
|
|
|
export function nativeChatCustomQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
|
|
const question = interaction.payload.questions[0];
|
|
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "single" || !question.allowOther ||
|
|
!interaction.payload.questionSet?.questions[0]?.customAnswer) return null;
|
|
const options = question.options.filter(option => !option.freeText);
|
|
return options.length > 0 && options.length <= 100 ? { ...question, options } : null;
|
|
}
|
|
|
|
export function nativeChatMultiQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
|
|
const question = interaction.payload.questions[0];
|
|
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "multi" || question.allowOther || question.options.length === 0 || question.options.length > 100 || question.options.some(option => option.freeText)) return null;
|
|
return question;
|
|
}
|
|
|
|
export function nativeChatSuggestions(interaction: IssueThreadInteraction): SuggestTasksInteraction | null {
|
|
return interaction.kind === "suggest_tasks" && interaction.payload.tasks.filter(task => !task.hiddenInPreview).length > 0 && interaction.payload.tasks.length <= 100 ? interaction : null;
|
|
}
|
|
|
|
export function nativeChatCheckbox(interaction: IssueThreadInteraction): RequestCheckboxConfirmationInteraction | null {
|
|
return interaction.kind === "request_checkbox_confirmation" && !interaction.payload.rejectRequiresReason &&
|
|
interaction.payload.target?.type !== "issue_document" && interaction.payload.options.length <= 100 ? interaction : null;
|
|
}
|
|
|
|
/**
|
|
* Slack and Telegram can render ordinary binary confirmations as inline buttons.
|
|
* Confirmations that collect a rejection reason or authorize a credential,
|
|
* connection, or tool side effect stay in Paperclip, where the complete
|
|
* governed review UI and permission checks are available.
|
|
*/
|
|
export function nativeChatConfirmation(
|
|
interaction: IssueThreadInteraction,
|
|
): RequestConfirmationInteraction | null {
|
|
if (interaction.kind !== "request_confirmation") return null;
|
|
if (
|
|
interaction.payload.rejectRequiresReason === true ||
|
|
interaction.payload.toolAction !== undefined ||
|
|
interaction.payload.secretProposal !== undefined ||
|
|
interaction.payload.connectionAuthorization !== undefined ||
|
|
interaction.payload.target?.type === "issue_document"
|
|
) {
|
|
return null;
|
|
}
|
|
return interaction;
|
|
}
|
|
|
|
/**
|
|
* Enqueues one immutable outbox item per live external task binding. A lone
|
|
* closed single-select question receives compact executable buttons. Slack and
|
|
* Teams can also receive an opaque modal opener for safe text and closed
|
|
* single-select forms. Unsupported question shapes stay link-only.
|
|
*/
|
|
export async function enqueueIssueInteractionChatPublications(
|
|
db: ChatPublicationDb,
|
|
interaction: IssueThreadInteraction,
|
|
) {
|
|
if (interaction.status !== "pending") return [];
|
|
// An endpoint is one immutable provider bot identity. Never externalize a
|
|
// user/system-authored interaction, or let one agent speak through another
|
|
// agent's endpoint. If the interaction names a source run, verify that run's
|
|
// company and agent instead of trusting the denormalized creator alone.
|
|
if (!interaction.createdByAgentId) return [];
|
|
if (interaction.sourceRunId) {
|
|
const sourceRun = await db
|
|
.select({ agentId: heartbeatRuns.agentId })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, interaction.sourceRunId),
|
|
eq(heartbeatRuns.companyId, interaction.companyId),
|
|
eq(heartbeatRuns.agentId, interaction.createdByAgentId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!sourceRun) return [];
|
|
}
|
|
const bindings = await db
|
|
.select({
|
|
conversation: chatConversations,
|
|
endpoint: chatEndpoints,
|
|
})
|
|
.from(chatConversations)
|
|
.innerJoin(
|
|
chatEndpoints,
|
|
and(
|
|
eq(chatEndpoints.companyId, chatConversations.companyId),
|
|
eq(chatEndpoints.id, chatConversations.endpointId),
|
|
eq(chatEndpoints.publicationMode, "automatic"),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(chatConversations.companyId, interaction.companyId),
|
|
eq(chatConversations.issueId, interaction.issueId),
|
|
inArray(chatConversations.state, ["active", "waiting"]),
|
|
inArray(chatEndpoints.status, ["active", "verifying"]),
|
|
),
|
|
);
|
|
if (bindings.length === 0) return [];
|
|
|
|
const taskUrl = publicChatInteractionTaskUrl(interaction.issueId);
|
|
const inserted: Array<typeof chatPublications.$inferSelect> = [];
|
|
for (const { conversation, endpoint } of bindings) {
|
|
if (endpoint.assignedAgentId !== interaction.createdByAgentId) continue;
|
|
if (endpoint.provider !== "slack" && interaction.kind !== "ask_user_questions" && interaction.kind !== "request_confirmation") continue;
|
|
let connectionDecline = endpoint.provider === "slack" && endpoint.capabilities.actions && interaction.kind === "connection_intent" ? interaction : null;
|
|
let suggestions = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatSuggestions(interaction) : null;
|
|
const suggestedOptions = suggestions?.payload.tasks.filter(task => !task.hiddenInPreview).map(task => ({ actionId: createChatQuestionOptionActionToken(), option: { id: task.clientKey, label: task.title, description: task.description } })) ?? [];
|
|
let checklist = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatCheckbox(interaction) : null;
|
|
const checklistOptions = checklist?.payload.options.map(option => ({ actionId: createChatQuestionOptionActionToken(), option })) ?? [];
|
|
const customQuestion = endpoint.provider === "slack" && endpoint.capabilities.modals === true && interaction.kind === "ask_user_questions" ? nativeChatCustomQuestion(interaction) : null;
|
|
const multipleQuestion = endpoint.provider === "slack" && interaction.kind === "ask_user_questions" ? nativeChatMultiQuestion(interaction) : null;
|
|
const question = interaction.kind === "ask_user_questions"
|
|
? multipleQuestion ?? customQuestion ?? nativeChatQuestion(interaction, endpoint.provider === "slack" ? 100 : MAX_NATIVE_QUESTION_OPTIONS) : null;
|
|
const inlineSelection = endpoint.provider === "slack" && question && (multipleQuestion !== null || question.options.length > 5);
|
|
const multipleActionId = multipleQuestion ? createChatQuestionOptionActionToken() : null;
|
|
const presentation = chatInteractionPresentation(interaction, { compactChoices: Boolean(inlineSelection || checklist || suggestedOptions.length > 1), nativeCustomAnswer: Boolean(customQuestion) });
|
|
const requiresBoard = endpoint.provider === "slack" && slackInteractionRequiresBoard(presentation.body);
|
|
if (requiresBoard) {
|
|
connectionDecline = null;
|
|
suggestions = null;
|
|
checklist = null;
|
|
}
|
|
const formInteraction = chatInteractionForm(interaction, endpoint.provider);
|
|
const formDraft =
|
|
!requiresBoard && (!inlineSelection || customQuestion) && formInteraction &&
|
|
(endpoint.provider === "slack" ||
|
|
endpoint.provider === "discord" ||
|
|
endpoint.provider === "microsoft-teams") &&
|
|
endpoint.capabilities.actions === true &&
|
|
endpoint.capabilities.modals === true
|
|
? createChatQuestionFormDraft(
|
|
formInteraction,
|
|
(endpoint.provider === "discord" || endpoint.provider === "slack")
|
|
? { nativeProvider: endpoint.provider }
|
|
: {},
|
|
)
|
|
: null;
|
|
const supportsCallbacks =
|
|
!requiresBoard && endpoint.provider !== "imessage-photon" &&
|
|
(formDraft === null || customQuestion !== null) &&
|
|
question !== null &&
|
|
endpoint.capabilities.actions === true;
|
|
const questionActionTokens = supportsCallbacks
|
|
? question.options.map((option) => ({
|
|
actionId: createChatQuestionOptionActionToken(),
|
|
option,
|
|
}))
|
|
: [];
|
|
const confirmation =
|
|
!requiresBoard && (endpoint.provider === "telegram" || endpoint.provider === "slack") && endpoint.capabilities.actions === true
|
|
? nativeChatConfirmation(interaction) ?? checklist
|
|
: null;
|
|
const confirmationActionTokens = confirmation || suggestions || connectionDecline
|
|
? (connectionDecline ? ["reject"] as const : ["accept", "reject"] as const).map((decision) => ({
|
|
actionId: createChatConfirmationActionToken(),
|
|
decision,
|
|
}))
|
|
: [];
|
|
if (
|
|
endpoint.provider === "telegram" &&
|
|
[...questionActionTokens, ...confirmationActionTokens].some(
|
|
({ actionId }) =>
|
|
telegramCallbackDataByteLength(actionId) >
|
|
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
|
|
)
|
|
) {
|
|
throw new Error("Generated Telegram question action exceeds 64 bytes");
|
|
}
|
|
const actions: SafeExternalChatCardAction[] = formDraft
|
|
? [
|
|
...(customQuestion && !inlineSelection ? questionActionTokens.map(({ actionId, option }) => ({ type: "callback" as const, actionId, label: option.label })) : []),
|
|
{
|
|
type: "callback" as const,
|
|
actionId: formDraft.openActionId,
|
|
label: customQuestion ? "Other…" : interaction.kind === "request_item_verdicts" ? "Review decisions" : "Respond",
|
|
style: customQuestion ? "default" as const : "primary" as const,
|
|
},
|
|
]
|
|
: supportsCallbacks
|
|
? (inlineSelection ? [] : questionActionTokens).map(({ actionId, option }) => ({
|
|
type: "callback" as const,
|
|
actionId,
|
|
label: option.label,
|
|
}))
|
|
: confirmation || suggestions || connectionDecline
|
|
? confirmationActionTokens.filter(item => !(checklist || suggestedOptions.length > 1) || item.decision !== "accept").map(({ actionId, decision }) => ({
|
|
type: "callback" as const,
|
|
actionId,
|
|
label:
|
|
decision === "accept"
|
|
? (suggestions ? "Create task" : confirmation!.payload.acceptLabel ?? "Accept")
|
|
: (connectionDecline ? "Not now" : suggestions ? "Dismiss" : confirmation!.payload.rejectLabel ?? "Reject"),
|
|
style:
|
|
decision === "accept"
|
|
? ("primary" as const)
|
|
: (connectionDecline || suggestions ? "default" as const : "danger" as const),
|
|
}))
|
|
: taskUrl
|
|
? [
|
|
{
|
|
type: "link" as const,
|
|
label: "Open in Paperclip",
|
|
url: taskUrl,
|
|
},
|
|
]
|
|
: [];
|
|
if (connectionDecline && taskUrl) actions.unshift({ type: "link", label: "Review connection", url: taskUrl });
|
|
if (suggestions && taskUrl) actions.push({ type: "link", label: "Revise in Paperclip", url: taskUrl });
|
|
const text = [presentation.title, presentation.body, taskUrl ? `Open the task in Paperclip to respond: ${taskUrl}` : "Open the task in Paperclip to respond."].join("\n\n");
|
|
const payload = projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "issue_interaction",
|
|
text,
|
|
progressState: "waiting_for_input",
|
|
interaction: {
|
|
id: interaction.id,
|
|
card: {
|
|
kind:
|
|
interaction.kind === "ask_user_questions"
|
|
? "question"
|
|
: interaction.kind === "request_confirmation"
|
|
? "confirmation"
|
|
: "status",
|
|
title: interaction.kind === "ask_user_questions" && question ? question.prompt : presentation.title,
|
|
body: presentation.body,
|
|
actions,
|
|
...(suggestions && suggestedOptions.length > 1 ? { selection: {
|
|
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
|
|
label: "Select tasks to create", multiple: true, submitLabel: "Create selected",
|
|
options: suggestedOptions.map(({ actionId, option }) => ({ actionId, label: option.label, ...(option.description ? { description: option.description } : {}) })),
|
|
} } : {}),
|
|
...(checklist ? { selection: {
|
|
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
|
|
label: "Select checks", multiple: true, submitLabel: checklist.payload.acceptLabel ?? "Save checks",
|
|
options: checklistOptions.map(({ actionId, option }) => ({ actionId, label: option.label,
|
|
...(option.description ? { description: option.description } : {}),
|
|
selected: checklist.payload.defaultSelectedOptionIds?.includes(option.id) ?? false })),
|
|
} } : {}),
|
|
...(inlineSelection && supportsCallbacks ? { selection: {
|
|
actionId: multipleActionId ?? `pcs:${interaction.id}`,
|
|
label: multipleQuestion ? "Choose one or more" : "Choose an answer",
|
|
...(multipleQuestion ? { multiple: true, submitLabel: "Submit answer" } : {}),
|
|
options: questionActionTokens.map(({ actionId, option }) => ({ actionId,
|
|
label: option.label + (interaction.kind === "ask_user_questions" && interaction.payload.questionSet?.questions.find(item => item.id === question.id)?.options?.find(item => item.id === option.id)?.recommended ? " (recommended)" : ""),
|
|
...(option.description ? { description: option.description } : {}) })),
|
|
} } : {}),
|
|
},
|
|
},
|
|
});
|
|
const rows = await db.transaction(async (tx) => {
|
|
await notifyChatPublicationWork(tx);
|
|
const rows = await tx
|
|
.insert(chatPublications)
|
|
.values({
|
|
companyId: interaction.companyId,
|
|
endpointId: endpoint.id,
|
|
conversationId: conversation.id,
|
|
issueId: interaction.issueId,
|
|
idempotencyKey: `interaction:${interaction.id}:${endpoint.id}`,
|
|
payload,
|
|
state: "pending",
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
const publication = rows[0];
|
|
if (publication && endpoint.provider === "imessage-photon" && nativePhotonInteraction(interaction)) {
|
|
const reference = randomBytes(9).toString("base64url");
|
|
await tx.insert(chatActions).values({ companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
|
|
kind: "photon_interaction", providerActionId: `photon:${reference}`,
|
|
payload: { version: 1, reference, interactionId: interaction.id, publicationId: publication.id, sessionGeneration: conversation.sessionGeneration,
|
|
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() }, status: "issued" });
|
|
} else if (publication && formDraft) {
|
|
await tx.insert(chatActions).values(
|
|
chatQuestionFormActionRecords(formDraft, {
|
|
companyId: interaction.companyId,
|
|
endpointId: endpoint.id,
|
|
conversationId: conversation.id,
|
|
publicationId: publication.id,
|
|
}),
|
|
);
|
|
}
|
|
if (publication && supportsCallbacks && multipleQuestion && multipleActionId) {
|
|
await tx.insert(chatActions).values({
|
|
companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
|
|
kind: "question_answer", providerActionId: multipleActionId, status: "issued",
|
|
payload: { version: 1, publicationId: publication.id, interactionId: interaction.id, questionId: multipleQuestion.id,
|
|
selectionOptions: questionActionTokens.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })),
|
|
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() },
|
|
});
|
|
} else if (publication && question && questionActionTokens.length > 0) {
|
|
const expiresAt = new Date(
|
|
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
|
|
).toISOString();
|
|
await tx.insert(chatActions).values(
|
|
questionActionTokens.map(({ actionId, option }) => ({
|
|
companyId: interaction.companyId,
|
|
endpointId: endpoint.id,
|
|
conversationId: conversation.id,
|
|
kind: "question_answer",
|
|
providerActionId: actionId,
|
|
payload: {
|
|
version: 1,
|
|
publicationId: publication.id,
|
|
interactionId: interaction.id,
|
|
questionId: question.id,
|
|
optionId: option.id,
|
|
expiresAt,
|
|
},
|
|
status: "issued",
|
|
})),
|
|
);
|
|
} else if (
|
|
publication &&
|
|
(confirmation || suggestions || connectionDecline) &&
|
|
confirmationActionTokens.length > 0
|
|
) {
|
|
const expiresAt = new Date(
|
|
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
|
|
).toISOString();
|
|
await tx.insert(chatActions).values(
|
|
confirmationActionTokens.map(({ actionId, decision }) => ({
|
|
companyId: interaction.companyId,
|
|
endpointId: endpoint.id,
|
|
conversationId: conversation.id,
|
|
kind: "confirmation_response",
|
|
providerActionId: actionId,
|
|
payload: {
|
|
version: 1,
|
|
publicationId: publication.id,
|
|
interactionId: interaction.id,
|
|
decision,
|
|
...(suggestions ? { selectionOptions: suggestedOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
|
|
...(checklist ? { selectionOptions: checklistOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
|
|
expiresAt,
|
|
},
|
|
status: "issued",
|
|
})),
|
|
);
|
|
}
|
|
return rows;
|
|
});
|
|
inserted.push(...rows);
|
|
}
|
|
return inserted;
|
|
}
|
|
|
|
/**
|
|
* Settles every delivered question or confirmation card. Providers without a
|
|
* native callback receive the same actionless terminal edit/follow-up as
|
|
* providers with buttons, so an "Open in Paperclip" prompt never remains
|
|
* visibly pending after the authoritative board decision.
|
|
*
|
|
* The terminal publication shares the provider callback idempotency key. If a
|
|
* provider click wins the race, its handler converges on the same row; if the
|
|
* Paperclip UI wins, all still-issued callback tokens expire in this same
|
|
* authoritative resolution transaction.
|
|
*/
|
|
export async function enqueueTerminalIssueInteractionChatPublications(
|
|
db: ChatPublicationDb,
|
|
interaction: IssueThreadInteraction,
|
|
) {
|
|
if (interaction.status === "pending") return [];
|
|
const originals = (
|
|
await db
|
|
.select()
|
|
.from(chatPublications)
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, interaction.companyId),
|
|
eq(chatPublications.issueId, interaction.issueId),
|
|
eq(
|
|
sql<string>`${chatPublications.payload}->>'interactionId'`,
|
|
interaction.id,
|
|
),
|
|
),
|
|
)
|
|
).filter(
|
|
(publication) =>
|
|
publication.idempotencyKey ===
|
|
`interaction:${interaction.id}:${publication.endpointId}`,
|
|
);
|
|
if (originals.length === 0) return [];
|
|
|
|
await db
|
|
.update(chatActions)
|
|
.set({
|
|
status: "expired",
|
|
result: { code: "interaction_resolved_elsewhere" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, interaction.companyId),
|
|
inArray(chatActions.kind, [
|
|
"photon_interaction",
|
|
"question_answer",
|
|
"question_form_open",
|
|
"question_form_submit",
|
|
"confirmation_response",
|
|
"photon_interaction",
|
|
]),
|
|
eq(chatActions.status, "issued"),
|
|
eq(
|
|
sql<string>`${chatActions.payload}->>'interactionId'`,
|
|
interaction.id,
|
|
),
|
|
),
|
|
);
|
|
|
|
const unsentIds = originals
|
|
.filter(
|
|
(publication) =>
|
|
publication.state === "pending" || publication.state === "retry",
|
|
)
|
|
.map((publication) => publication.id);
|
|
if (unsentIds.length > 0) {
|
|
await db.transaction(async (tx) => {
|
|
await notifyChatPublicationWork(tx);
|
|
return tx
|
|
.update(chatPublications)
|
|
.set({
|
|
state: "cancelled",
|
|
nextAttemptAt: null,
|
|
redactedError: "Interaction was resolved before provider publication",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
inArray(chatPublications.id, unsentIds),
|
|
inArray(chatPublications.state, ["pending", "retry"]),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
const originalIds = originals.map((publication) => publication.id);
|
|
// The dispatcher may claim pending -> streaming after the first read but
|
|
// before the cancellation CAS. Re-read every executable original after that
|
|
// CAS so a loser is treated as potentially provider-visible and receives a
|
|
// terminal replacement instead of being decided from the stale snapshot.
|
|
// A published row without a provider id is also potentially visible: that
|
|
// is the durable result of an operator choosing `mark_delivered` after an
|
|
// ambiguous send, so resolution must post a terminal follow-up rather than
|
|
// leaving the external card looking actionable forever.
|
|
const currentOriginals =
|
|
originalIds.length > 0
|
|
? await db
|
|
.select()
|
|
.from(chatPublications)
|
|
.where(inArray(chatPublications.id, originalIds))
|
|
: [];
|
|
const providerVisibleOriginals = currentOriginals.filter(
|
|
(original) =>
|
|
original.state === "streaming" ||
|
|
original.state === "delivery_unknown" ||
|
|
original.state === "published",
|
|
);
|
|
const planTarget =
|
|
interaction.kind === "request_confirmation" &&
|
|
interaction.payload.target?.type === "issue_document" &&
|
|
interaction.payload.target.issueId === interaction.issueId &&
|
|
interaction.payload.target.key === "plan"
|
|
? interaction.payload.target
|
|
: null;
|
|
const rejectedPlanNeedsRevision =
|
|
interaction.status === "rejected" && planTarget !== null;
|
|
const resolutionOutcome = (interaction.result as { outcome?: unknown } | null)
|
|
?.outcome;
|
|
const continuationWakeRequired =
|
|
(interaction.kind === "ask_user_questions" || interaction.kind === "request_confirmation" || interaction.kind === "request_item_verdicts") &&
|
|
interaction.status !== "expired" &&
|
|
resolutionOutcome !== "skipped" &&
|
|
!(
|
|
interaction.kind === "ask_user_questions" &&
|
|
interaction.status === "answered"
|
|
) &&
|
|
(interaction.continuationPolicy === "wake_assignee" ||
|
|
(interaction.continuationPolicy === "wake_assignee_on_accept" &&
|
|
(interaction.status === "accepted" ||
|
|
interaction.status === "answered")) ||
|
|
rejectedPlanNeedsRevision);
|
|
if (continuationWakeRequired && currentOriginals.length > 0) {
|
|
const issue = await db
|
|
.select({
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
status: issues.status,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, interaction.companyId),
|
|
eq(issues.id, interaction.issueId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
const requestedByActorType = interaction.resolvedByUserId
|
|
? "user"
|
|
: interaction.resolvedByAgentId
|
|
? "agent"
|
|
: "system";
|
|
const requestedByActorId =
|
|
interaction.resolvedByUserId ??
|
|
interaction.resolvedByAgentId ??
|
|
"system:interaction-resolution";
|
|
const wakeBinding = currentOriginals[0]!;
|
|
if (
|
|
issue?.assigneeAgentId &&
|
|
issue.status !== "done" &&
|
|
issue.status !== "cancelled" &&
|
|
!(
|
|
resolutionOutcome === "withdrawn" &&
|
|
interaction.resolvedByAgentId === issue.assigneeAgentId
|
|
)
|
|
) {
|
|
await db.transaction(async tx => {
|
|
await notifyChatContinuationWork(tx, { companyId: interaction.companyId, issueId: interaction.issueId });
|
|
await tx
|
|
.insert(chatActions)
|
|
.values({
|
|
companyId: interaction.companyId,
|
|
endpointId: wakeBinding.endpointId,
|
|
conversationId: wakeBinding.conversationId,
|
|
kind: "interaction_wakeup",
|
|
providerActionId: `interaction_wakeup:${interaction.id}`,
|
|
payload: {
|
|
version: 1,
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
issueId: interaction.issueId,
|
|
agentId: issue.assigneeAgentId,
|
|
sourceCommentId: interaction.sourceCommentId ?? null,
|
|
sourceRunId: interaction.sourceRunId ?? null,
|
|
requestedByActorType,
|
|
requestedByActorId,
|
|
...(planTarget
|
|
? {
|
|
planReviewInteraction: {
|
|
id: interaction.id,
|
|
kind: interaction.kind,
|
|
status: interaction.status,
|
|
target: planTarget,
|
|
acceptedTargetRevision:
|
|
interaction.status === "accepted" ? planTarget : null,
|
|
result: interaction.result,
|
|
},
|
|
}
|
|
: {}),
|
|
...(interaction.status === "accepted" && planTarget
|
|
? {
|
|
forceFreshSession: true,
|
|
workspaceRefreshReason: "accepted_plan_confirmation",
|
|
}
|
|
: {}),
|
|
...(interaction.resolvedByUserId
|
|
? { requestedByUserId: interaction.resolvedByUserId }
|
|
: {}),
|
|
},
|
|
status: "issued",
|
|
})
|
|
.onConflictDoNothing();
|
|
});
|
|
}
|
|
}
|
|
|
|
const copy = terminalNativeInteractionCopy(interaction);
|
|
if (!copy) return [];
|
|
const inserted: Array<typeof chatPublications.$inferSelect> = [];
|
|
for (const original of providerVisibleOriginals) {
|
|
const card = original.payload.card;
|
|
if (!card) continue;
|
|
const rows = await db.transaction(async (tx) => {
|
|
await notifyChatPublicationWork(tx);
|
|
return tx
|
|
.insert(chatPublications)
|
|
.values({
|
|
companyId: interaction.companyId,
|
|
endpointId: original.endpointId,
|
|
conversationId: original.conversationId,
|
|
issueId: interaction.issueId,
|
|
idempotencyKey: `interaction-resolution:${interaction.id}:${original.endpointId}`,
|
|
payload: projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "issue_interaction",
|
|
text: copy.text,
|
|
interaction: {
|
|
id: interaction.id,
|
|
card: {
|
|
kind: card.kind,
|
|
title: card.title,
|
|
body: copy.body,
|
|
actions: [],
|
|
},
|
|
},
|
|
}),
|
|
state: "pending",
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
});
|
|
inserted.push(...rows);
|
|
}
|
|
return inserted;
|
|
}
|
|
|
|
/** Prevents an unstarted stale question card from being sent after replacement. */
|
|
export async function cancelPendingIssueInteractionChatPublications(
|
|
db: ChatPublicationDb,
|
|
input: {
|
|
companyId: string;
|
|
issueId: string;
|
|
interactionIds: readonly string[];
|
|
},
|
|
) {
|
|
if (input.interactionIds.length === 0) return [];
|
|
await db
|
|
.update(chatActions)
|
|
.set({
|
|
status: "expired",
|
|
result: { code: "interaction_superseded_before_publication" },
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatActions.companyId, input.companyId),
|
|
inArray(chatActions.kind, [
|
|
"question_answer",
|
|
"question_form_open",
|
|
"question_form_submit",
|
|
"confirmation_response",
|
|
"photon_interaction",
|
|
]),
|
|
eq(chatActions.status, "issued"),
|
|
inArray(sql<string>`${chatActions.payload}->>'interactionId'`, [
|
|
...input.interactionIds,
|
|
]),
|
|
),
|
|
);
|
|
return db.transaction(async (tx) => {
|
|
await notifyChatPublicationWork(tx);
|
|
return tx
|
|
.update(chatPublications)
|
|
.set({
|
|
state: "cancelled",
|
|
nextAttemptAt: null,
|
|
redactedError: "Interaction was superseded before publication",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(chatPublications.companyId, input.companyId),
|
|
eq(chatPublications.issueId, input.issueId),
|
|
inArray(chatPublications.state, ["pending", "retry"]),
|
|
inArray(sql<string>`${chatPublications.payload}->>'interactionId'`, [
|
|
...input.interactionIds,
|
|
]),
|
|
),
|
|
)
|
|
.returning();
|
|
});
|
|
}
|