From aaf1337c7ba615df3021607fbad9ddc108403bcd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:26:52 -0600 Subject: [PATCH 1/5] fix(slack): require mentions only for channel roots (#20821) ## Thinking Path > - Paperclip helps people manage AI agents for work. > - Slack connects people to those agents through channels and threads. > - A mention can start a conversation without making the bot answer all channel traffic. > - The current setting also requires mentions on every thread reply. > - This interrupts an existing conversation and can cancel a queued reply. > - This change requires mentions on channel roots and allows replies without a mention. > - People can continue a thread without adding the bot name to each message. ## Linked Issues or Issue Description **What existing behavior does this improve?** The Slack connection setting that requires an at-mention. **Current behavior** When the setting is on, a channel thread reply must mention the bot. The delivery and execution checks reject a reply that has no mention. **Proposed behavior** Require a mention on a top-level channel message. Allow replies in existing threads without a mention. Keep the current DM access controls. **Reason and benefit** A person can continue an existing conversation without repeating the bot name. **Breaking changes** Existing connections with the setting on will accept thread replies without a mention. The saved setting, default value, and API field stay the same. ## What Changed - Rename the control to **Require at-mention in channels**. Explain the thread and DM rules in the help text and operator documentation. - Keep the mention policy in the Slack module. Use the thread root and message IDs to identify a reply. - Call that policy from durable admission, wake authorization, and queued run start. These shared paths must enforce the same setting when a delivery resumes. Other providers keep their current behavior. - Update PostgreSQL integration tests for thread replies, DMs, queued deliveries, and combined wake inputs. Add tests that reject incomplete reply identity. ## Verification - Reproduced rejected thread replies and cancelled queued reply runs before the fix. - Passed 16 focused PostgreSQL integration cases after the fix. - Passed 71 tests on revision `31b1adede2b92bad24b8fa21b8f6fffa9bd26d04` after the rebase. This includes the full durable wakeup suite, Slack policy tests, and eight selected Slack delivery cases. - Command: `pnpm exec vitest run server/src/services/chat-channels/slack/wake-authority.test.ts server/src/__tests__/durable-chat-wakeup.test.ts server/src/__tests__/chat-channels.integration.test.ts -t "at-mentions by default|thread replies without mentions|rechecks queued Slack|preserves running Slack tool authority|Slack channel mention policy|durable inbound chat scheduler receipts"`. - Strengthened the queued-root test to check the mention-rejection reason and content redaction. A temporary change that disabled mention enforcement caused this test to fail with the unrelated-message reason. The real policy is restored. - Removed the added source comments to follow the contribution rules. - `node scripts/check-module-boundaries.mjs` and `git diff --check` pass. - `pnpm verify` returns exit 2 because the operator documentation is a global CI input. Required CI must supply the wider coverage. - Local preflight installed dependencies and built the plugin SDK. It could not build Runner Rust binaries because this environment has no `cargo` executable. The focused server tests run without those binaries. - Live Slack and browser tests were not run. This change uses the existing provider message and thread identities. - Required CI passed on `31b1adede2b92bad24b8fa21b8f6fffa9bd26d04`: [PR workflow](https://github.com/paperclipai/paperclip/actions/runs/43035551294), including typecheck, build, server/chat tests, Runner checks, and browser E2E. Current-head Greptile and Paperclip reviews are both 5/5 with no open findings. ## Risks - Existing connections with mention gating on will now accept unmentioned thread replies. This is the intended behavior change. - A missing or invalid thread or message ID does not grant the reply exception. - No schema migration is required. Destination, sender, and company access checks stay in place. - The existing chat integration test file is large. This change updates its current Slack cases to reuse the full delivery and authorization fixture. ## Model Used OpenAI Codex, based on GPT-6, with reasoning and code execution tools. ## 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 - [x] All Paperclip CI gates are green - [x] 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 --- doc/connections/SLACK-TASK-TOOLS.md | 16 +++---- .../chat-channels.integration.test.ts | 48 ++++++++++++------- .../src/__tests__/durable-chat-wakeup.test.ts | 10 ++-- .../chat-channels/inbound/admission.ts | 6 +-- .../services/chat-channels/slack/messages.ts | 12 +++-- .../slack/wake-authority.test.ts | 48 +++++++++++++++++++ .../chat-channels/slack/wake-authority.ts | 44 ++++++++++++++--- server/src/services/durable-chat-wakeup.ts | 5 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 4 +- 9 files changed, 143 insertions(+), 50 deletions(-) create mode 100644 server/src/services/chat-channels/slack/wake-authority.test.ts diff --git a/doc/connections/SLACK-TASK-TOOLS.md b/doc/connections/SLACK-TASK-TOOLS.md index 351cc8ef79e..68c4e573ed9 100644 --- a/doc/connections/SLACK-TASK-TOOLS.md +++ b/doc/connections/SLACK-TASK-TOOLS.md @@ -207,18 +207,18 @@ ordinary task finals are not automatically broadcast to Slack. ## Require an at-mention for inbound channel messages -Slack app settings include **Require at-mention** directly below **Allow direct -messages**. It defaults to on for new Slack connections. Existing connections +Slack app settings include **Require at-mention in channels** directly below +**Allow direct messages**. It defaults to on for new Slack connections. Existing connections keep their saved setting; the setting applies only to channels. -When enabled, each inbound channel message must explicitly @mention the connected -bot, including replies in existing channel threads. Direct messages never require +When enabled, each top-level channel message must explicitly @mention the connected +bot. Replies in existing channel threads do not require an @mention. Direct messages never require an @mention, but must be enabled with **Allow direct messages** and pass the usual sender authorization checks. Top-level DMs remain a linear conversation with top-level replies; use a private channel for conversations organized into threads. -Unmentioned channel messages do not create or continue a task. Queued messages +Unmentioned top-level channel messages do not create a task. Queued messages are checked again before starting agent work. If a queued run combines several -channel messages, an unmentioned input cancels that queued run when this setting -is enabled. Send a new mention to start it again. Unmentioned DM inputs do not -trigger this cancellation. +channel messages, an unmentioned top-level input cancels that queued run when this setting +is enabled. Send a new mention to start it again. Unmentioned thread replies and +DM inputs do not trigger this cancellation. This setting does not cancel work that has already started or revoke its Slack tool access. diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 909f9bd60a8..edee564c8b5 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -11666,7 +11666,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ slackUserId: "UBOARD" }); }); - it.each([false, true])("applies Slack mention settings to channel replies while exempting DMs (DM=%s)", async (isDM) => { + it.each([false, true])("allows Slack thread replies without mentions while exempting DMs (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture); expect((await service.get(endpoint.id)).requireAtMention).toBe(false); @@ -11680,29 +11680,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(wakeup).toHaveBeenCalledTimes(2); expect((await service.update(endpoint.id, { requireAtMention: true })).requireAtMention).toBe(true); await send("7100.3", false, "subscribed_message"); - expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2); + expect(wakeup).toHaveBeenCalledTimes(3); const [filtered] = await db.select().from(chatDeliveries).where(and( eq(chatDeliveries.endpointId, endpoint.id), eq(chatDeliveries.state, "filtered"))); - if (isDM) { - expect(filtered).toBeUndefined(); - } else { - expect(filtered.redactedError).toBe("Message did not @mention the bot"); - expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); - expect(filtered.normalizedEvent).not.toHaveProperty("message.text"); - } + expect(filtered).toBeUndefined(); // Both Slack callback kinds can carry an explicit mention. Retries cannot // produce a second wakeup for the same provider message. await send("7100.4", true); await send("7100.4", true, "mention"); - expect(wakeup).toHaveBeenCalledTimes(isDM ? 4 : 3); + expect(wakeup).toHaveBeenCalledTimes(4); const conversations = await readChatConversationsByEndpointId(endpoint.id); expect(conversations).toHaveLength(1); const comments = await readIssueCommentsByIssueId(conversations[0].issueId); - expect(comments).toHaveLength(isDM ? 4 : 3); + expect(comments).toHaveLength(4); expect(conversations[0].externalThreadId).toBe(thread.thread.id); await service.update(endpoint.id, { requireAtMention: false }); await send("7100.5", false); - expect(wakeup).toHaveBeenCalledTimes(isDM ? 5 : 4); + expect(wakeup).toHaveBeenCalledTimes(5); if (isDM) { await service.update(endpoint.id, { requireAtMention: true, allowDirectMessages: false }); await send("7100.6", false); @@ -11710,7 +11704,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { } }); - it.each([false, true])("rechecks queued Slack channel mentions but exempts DMs before drain (DM=%s)", async (isDM) => { + it.each([ + { isDM: false, isReply: false }, + { isDM: false, isReply: true }, + { isDM: true, isReply: false }, + ])("rechecks queued Slack channel roots but exempts replies and DMs (DM=$isDM, reply=$isReply)", async ({ isDM, isReply }) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { deferWebhookProcessing: true, scheduleDeferredWork: () => undefined, @@ -11718,14 +11716,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const channelId = isDM ? "D-QUEUED-MENTION" : "C-QUEUED-MENTION"; const thread = makeThread({ channelId, id: `slack:${channelId}:${isDM ? "" : "7300.1"}`, isDM }); await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, - message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: isDM ? "direct_message" : "subscribed_message" }); + message: makeMessage({ id: "7300.1", text: "@maya start", mentioned: true }), trigger: isDM ? "direct_message" : "mention" }); + const [starter] = await readChatDeliveriesByEndpointId(endpoint.id); + await db.update(chatDeliveries).set({ nextAttemptAt: new Date(0) }).where(eq(chatDeliveries.id, starter.id)); + await service.processPendingDeliveries(25, starter.id); + expect(wakeup).toHaveBeenCalledTimes(1); + const nextThread = isReply || isDM ? thread.thread : makeThread({ channelId, id: `slack:${channelId}:7300.2` }).thread; + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: nextThread, + message: makeMessage({ id: "7300.2", text: "Continue work" }), trigger: isDM ? "direct_message" : "subscribed_message" }); await service.update(endpoint.id, { requireAtMention: true }); - const [delivery] = await readChatDeliveriesByEndpointId(endpoint.id); + const delivery = (await readChatDeliveriesByEndpointId(endpoint.id)).find(row => row.id !== starter.id)!; await db.update(chatDeliveries).set({ nextAttemptAt: new Date(0) }).where(eq(chatDeliveries.id, delivery.id)); await service.processPendingDeliveries(25, delivery.id); - expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0); - expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(isDM ? 1 : 0); - expect((await readChatDeliveriesById(delivery.id))[0].state).toBe(isDM ? "processed" : "filtered"); + const allowed = isDM || isReply; + expect(wakeup).toHaveBeenCalledTimes(allowed ? 2 : 1); + expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(1); + const [result] = await readChatDeliveriesById(delivery.id); + expect(result.state).toBe(allowed ? "processed" : "filtered"); + if (!allowed) { + expect(result.redactedError).toBe("Message did not @mention the bot"); + expect(result.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); + expect(result.normalizedEvent).not.toHaveProperty("message.text"); + } }); it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => { diff --git a/server/src/__tests__/durable-chat-wakeup.test.ts b/server/src/__tests__/durable-chat-wakeup.test.ts index ceb16cec24d..6168d8eee74 100644 --- a/server/src/__tests__/durable-chat-wakeup.test.ts +++ b/server/src/__tests__/durable-chat-wakeup.test.ts @@ -197,11 +197,11 @@ describe("durable inbound chat scheduler receipts", () => { } it.each([ - ...["unmentioned", "mentioned", "coalesced", "recovery", "deferred"].map((scenario) => ({ scenario, isDM: false })), + ...["unmentioned", "mentioned", "reply", "coalesced_reply", "coalesced", "recovery", "deferred"].map((scenario) => ({ scenario, isDM: false })), ...["unmentioned", "coalesced"].map((scenario) => ({ scenario, isDM: true })), ])("checks Slack channel mentions after a real heartbeat queue wait ($scenario, DM=$isDM)", async ({ scenario, isDM }) => { const mentioned = scenario === "mentioned" || scenario === "coalesced"; - const allowed = isDM || scenario === "mentioned" || scenario === "recovery"; + const allowed = isDM || scenario === "mentioned" || scenario === "recovery" || scenario === "reply" || scenario === "coalesced_reply"; const f = await fixture(); await db.update(agents).set({ adapterType: "durable_chat_retry_test" }).where(eq(agents.id, f.agentId)); const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID(); @@ -215,7 +215,7 @@ describe("durable inbound chat scheduler receipts", () => { authorUserId: "board-user", body: mentioned ? "@bot work" : "Work" }); const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", - normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: mentioned } } }).returning(); + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM, externalThreadId: "slack:C-MENTION:7300.1" }, message: { providerMessageId: scenario === "reply" || scenario === "coalesced_reply" ? "7300.2" : "7300.1", mentionedBot: mentioned } } }).returning(); await db.insert(chatActions).values({ id: request.id, companyId: f.companyId, endpointId, deliveryId: delivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: request.commentId } }); @@ -231,13 +231,13 @@ describe("durable inbound chat scheduler receipts", () => { .where(eq(heartbeatRuns.id, receipt.runId!)); } let deferredRequestId: string | undefined; - if (scenario === "coalesced" || scenario === "deferred") { + if (scenario === "coalesced" || scenario === "coalesced_reply" || scenario === "deferred") { const second = f.request(); await db.insert(issueComments).values({ id: second.commentId, companyId: f.companyId, issueId: f.issueId, authorUserId: "board-user", body: scenario === "deferred" ? "@bot follow-up" : "Unmentioned follow-up" }); const [secondDelivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", - normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: scenario === "deferred" } } }).returning(); + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM, externalThreadId: "slack:C-MENTION:7300.1" }, message: { providerMessageId: scenario === "coalesced_reply" ? "7300.3" : "7300.1", mentionedBot: scenario === "deferred" } } }).returning(); await db.insert(chatActions).values({ id: second.id, companyId: f.companyId, endpointId, deliveryId: secondDelivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: second.commentId } }); diff --git a/server/src/services/chat-channels/inbound/admission.ts b/server/src/services/chat-channels/inbound/admission.ts index 34329d81557..e2638a83771 100644 --- a/server/src/services/chat-channels/inbound/admission.ts +++ b/server/src/services/chat-channels/inbound/admission.ts @@ -1,3 +1,4 @@ +import { slackMessageMeetsMentionRequirement } from "../slack/messages.js"; import { createTeamsDestinationAdmission } from "../microsoft-teams/destination-admission.js"; import { createTelegramDestinationAdmission } from "../telegram/destination-admission.js"; import { createPhotonDestinationAdmission } from "../imessage-photon/destination-admission.js"; @@ -167,10 +168,7 @@ export function createInboundAdmission(db: Db, options: InboundAdmissionOptions) } const mentionRequired = endpoint.provider === "slack" && - currentEndpoint.requireAtMention && - !thread.isDM && - trigger !== "mention" && - message.isMention !== true; + !slackMessageMeetsMentionRequirement(currentEndpoint, thread, trigger, message); // GitHub sends the same comment to every installed App. Subscribed // conversations are a fallback for unaddressed replies, not permission // to wake every bot when the person addresses a different recipient. diff --git a/server/src/services/chat-channels/slack/messages.ts b/server/src/services/chat-channels/slack/messages.ts index 36196eab18f..6981d2650cc 100644 --- a/server/src/services/chat-channels/slack/messages.ts +++ b/server/src/services/chat-channels/slack/messages.ts @@ -106,9 +106,15 @@ export function stripSlackTitleMentions(text: string): string { export function slackMessageMeetsMentionRequirement( endpoint: Pick, - thread: Pick, + thread: Pick, trigger: ChatSdkMessageCallbackEvent["trigger"], - message: Pick, + message: Pick, ): boolean { - return !endpoint.requireAtMention || thread.isDM || trigger === "mention" || message.isMention === true; + return ( + !endpoint.requireAtMention || + thread.isDM || + (Boolean(message.id) && slackOrphanFollowUp(thread.id, message.id)) || + trigger === "mention" || + message.isMention === true + ); } diff --git a/server/src/services/chat-channels/slack/wake-authority.test.ts b/server/src/services/chat-channels/slack/wake-authority.test.ts new file mode 100644 index 00000000000..1fb2f11467f --- /dev/null +++ b/server/src/services/chat-channels/slack/wake-authority.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { slackDeliveryMeetsMentionRequirement } from "./wake-authority.js"; + +describe("Slack channel mention policy", () => { + const root = { + trigger: "subscribed_message", + conversation: { externalThreadId: "slack:C123:7300.000001", isDirectMessage: false }, + message: { providerMessageId: "7300.000001", mentionedBot: false }, + }; + + it("requires a mention on a channel root, even for a subscribed callback", () => { + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, root)).toBe(false); + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: false }, root)).toBe(true); + }); + + it("allows replies with a known message ID in a channel thread", () => { + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, { + ...root, message: { ...root.message, providerMessageId: "7300.000002" }, + })).toBe(true); + }); + + it.each([ + {}, + { ...root, message: {} }, + { ...root, message: { providerMessageId: "" } }, + { ...root, message: { providerMessageId: 7300 } }, + { ...root, conversation: {} }, + { ...root, conversation: { externalThreadId: "slack:C123:" } }, + { ...root, conversation: { externalThreadId: "slack:C123" } }, + { ...root, conversation: { externalThreadId: "discord:C123:7300.000001" } }, + ])("does not infer a thread reply from incomplete or invalid identity (%j)", (event) => { + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, event)).toBe(false); + }); + + it("accepts either explicit mention callback", () => { + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, { ...root, trigger: "mention" })).toBe(true); + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, { + ...root, message: { ...root.message, mentionedBot: true }, + })).toBe(true); + }); + + it("exempts DMs and uses the current conversation type when it is supplied", () => { + const directMessage = { ...root, conversation: { ...root.conversation, isDirectMessage: true } }; + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, directMessage)).toBe(true); + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, root, true)).toBe(true); + expect(slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, directMessage, false)).toBe(false); + }); +}); diff --git a/server/src/services/chat-channels/slack/wake-authority.ts b/server/src/services/chat-channels/slack/wake-authority.ts index e09a7ff93a5..79662476879 100644 --- a/server/src/services/chat-channels/slack/wake-authority.ts +++ b/server/src/services/chat-channels/slack/wake-authority.ts @@ -1,14 +1,44 @@ import { forbidden } from "../../../errors.js"; +import { slackMessageMeetsMentionRequirement } from "./messages.js"; import type { EndpointRow } from "../runtime-types.js"; import type { chatConversations, chatDeliveries } from "@paperclipai/db"; -export function assertSlackWakeMention(endpoint: EndpointRow, conversation: Pick, delivery: Pick): void { - if ( - endpoint.requireAtMention && - !conversation.isDirectMessage && - delivery.normalizedEvent.trigger !== "mention" && - (delivery.normalizedEvent.message as { mentionedBot?: boolean } | undefined)?.mentionedBot !== true - ) { + +type SlackDeliveryEvent = typeof chatDeliveries.$inferSelect["normalizedEvent"]; + +export function slackDeliveryMeetsMentionRequirement( + endpoint: Pick, + event: SlackDeliveryEvent, + isDirectMessage?: boolean, +): boolean { + const conversation = event.conversation as { + externalThreadId?: unknown; + isDirectMessage?: boolean; + } | undefined; + const message = event.message as { + providerMessageId?: unknown; + mentionedBot?: boolean; + } | undefined; + return slackMessageMeetsMentionRequirement( + endpoint, + { + id: typeof conversation?.externalThreadId === "string" ? conversation.externalThreadId : "", + isDM: isDirectMessage ?? conversation?.isDirectMessage === true, + }, + event.trigger === "mention" ? "mention" : "subscribed_message", + { + id: typeof message?.providerMessageId === "string" ? message.providerMessageId : "", + isMention: message?.mentionedBot === true, + }, + ); +} + +export function assertSlackWakeMention( + endpoint: EndpointRow, + conversation: Pick, + delivery: Pick, +): void { + if (!slackDeliveryMeetsMentionRequirement(endpoint, delivery.normalizedEvent, conversation.isDirectMessage)) { throw forbidden("Message did not @mention the bot", { code: "chat_action_authorization_changed", }); diff --git a/server/src/services/durable-chat-wakeup.ts b/server/src/services/durable-chat-wakeup.ts index 367386ffaca..acfb4b4ca63 100644 --- a/server/src/services/durable-chat-wakeup.ts +++ b/server/src/services/durable-chat-wakeup.ts @@ -1,3 +1,4 @@ +import { slackDeliveryMeetsMentionRequirement } from "./chat-channels/slack/wake-authority.js"; import { createHash } from "node:crypto"; import { agentWakeupRequests, chatActions, chatConversations, chatDeliveries, chatEndpoints, type Db } from "@paperclipai/db"; import { and, eq, exists, inArray, or, sql, type SQLWrapper } from "drizzle-orm"; @@ -410,7 +411,5 @@ export async function slackMentionAllowsRunStart( ), )); return inputs.every(({ event }) => - (event.conversation as { isDirectMessage?: boolean } | undefined)?.isDirectMessage === true || - event.trigger === "mention" || - (event.message as { mentionedBot?: boolean } | undefined)?.mentionedBot === true); + slackDeliveryMeetsMentionRequirement({ requireAtMention: true }, event)); } diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 2560e7bee1c..476173a6cb9 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -621,8 +621,8 @@ function Settings({ /> {endpoint.provider === "slack" && ( From 81c9143fe46cf6daa3b5a756d62e3cbdfe7e5797 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:26:58 -0600 Subject: [PATCH 2/5] fix: upgrade Better Auth to 1.7.16 with account migration (#18906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages AI agents and their work. > - The board uses Better Auth for credential accounts and sessions. > - The server pins Better Auth 1.7.2. > - The November security release fixes 12 issues in Better Auth. > - The new version stops writing the old required account issuer field. > - This change updates the packages and the database contract together. > - New users can sign up, and existing users can still sign in. ## Linked Issues or Issue Description **What happened?** The server uses Better Auth 1.7.2. The [security release](https://better-auth.com/blog/security-release-2026-11-03) fixes 12 issues in 1.7.16. Earlier update PRs #17909 and #17911 target 1.7.7, which is below this security release. **Expected behavior** Use the patched stable release. Keep new and existing credential accounts usable. **Steps to reproduce** Read the server dependency pin. Upgrade it without a schema change. Better Auth then rejects the required account issuer column. See the [upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide). ## What Changed - Pin Better Auth to 1.7.16. The repository refresh bot owns the committed Core lockfile. - Make the old issuer column nullable. Keep existing issuer values. - Replace the issuer index with a unique provider/account index. - Generate the migration and retain the five newest schema snapshots. - Test new signup, new-account sign-in, legacy issuer sign-in, duplicate rejection, and separate provider identities with real PostgreSQL. - Update the database schema test. Test the historical issuer backfill separately. Verify upgrades over existing rows and rollback on duplicate provider/account keys. - Document the duplicate check and migration order. - Add the approved two-line dependency resolution step before the backup job’s frozen install. Leave the frozen check and backup tests intact. ## Verification - Core head: `af70282c7e24335b8688c8f3cc88fdf65045632c`; parent `0f1481e4462100357a7caa496b875cf0bc500d95` includes master `84fc67317`. - Nine changed files. The only workflow edit is the approved two-line backup dependency resolution step; no managed lockfile edits. - Migration `0465_better_auth_account_identity.sql` is three SQL statements; snapshot changes only `public.account`, five newest snapshots retained. - DB typecheck and 23 focused tests passed on the parent. Both real PostgreSQL migration/signup suites were rerun November 11: all 4 tests passed. Application source and tests are unchanged in this workflow-only commit. - The parent’s sandbox-provider lane passed all 205 file-sync tests but reported an unhandled EPIPE during the symlink rejection case; its aggregate verify therefore failed. No unrelated source change was made. The new head reruns the full applicable CI. - Reproduced the old backup install failure locally. Resolving manifests followed by a frozen install passed locally; restored the bot-owned committed lock afterward. Hosted backup qualification remains required. - Current-head CI and review are running; no final-head green claim or merge yet. ## Risks Apply the migration before the new server starts. Check for duplicate provider/account pairs first. Duplicate rows stop index creation. Keep separate users separate when resolving duplicates. The migration keeps issuer data. Do not restore its NOT NULL constraint while new rows contain null. No deployment is included. ## Model Used OpenAI GPT-6 Codex, with reasoning and tool use. The runtime did not expose a more specific model ID or context window. ## 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 --- .../backup-checkpoint-qualification.yml | 2 + doc/DATABASE.md | 23 +++ ...tter-auth-account-issuer-migration.test.ts | 90 +++++++---- .../0465_better_auth_account_identity.sql | 3 + ...{0460_snapshot.json => 0465_snapshot.json} | 147 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/auth.ts | 18 +-- server/package.json | 2 +- ...auth-credential-signup.integration.test.ts | 19 ++- 9 files changed, 252 insertions(+), 59 deletions(-) create mode 100644 packages/db/src/migrations/0465_better_auth_account_identity.sql rename packages/db/src/migrations/meta/{0460_snapshot.json => 0465_snapshot.json} (99%) diff --git a/.github/workflows/backup-checkpoint-qualification.yml b/.github/workflows/backup-checkpoint-qualification.yml index 3b90802cc13..d325d927e89 100644 --- a/.github/workflows/backup-checkpoint-qualification.yml +++ b/.github/workflows/backup-checkpoint-qualification.yml @@ -46,6 +46,8 @@ jobs: with: node-version: '24' cache: pnpm + - name: Resolve dependency manifests for this run + run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile - run: pnpm install --frozen-lockfile --ignore-scripts - name: Build workspace import prerequisites run: | diff --git a/doc/DATABASE.md b/doc/DATABASE.md index c5822df0f02..b231152f6d4 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -1,5 +1,28 @@ # Database +### Better Auth 1.7.16 upgrade + +Better Auth 1.7.3 and later identify accounts by `provider_id` and `account_id`. +They no longer write the `issuer` column that 1.7.0–1.7.2 required. Apply the +schema migration before starting the updated server. It makes `issuer` nullable, +keeps its existing values, removes the old issuer index, and adds a unique index +on the provider/account pair. This permits new credential signups and preserves +existing accounts. + +Before the upgrade, check for duplicate provider/account pairs: + +```sql +SELECT provider_id, account_id, count(*) +FROM account +GROUP BY provider_id, account_id +HAVING count(*) > 1; +``` + +Resolve any duplicates before migration. Keep different users separate. A duplicate +causes index creation to fail rather than leaving an ambiguous login identity. +Do not restore the issuer `NOT NULL` constraint on rollback while new rows have +null issuers. See the [Better Auth upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide). + ### Durable native provider backups `native_harness_backups` journals encrypted provider-session archives in object diff --git a/packages/db/src/better-auth-account-issuer-migration.test.ts b/packages/db/src/better-auth-account-issuer-migration.test.ts index a8264d99ec7..ed260aa9a15 100644 --- a/packages/db/src/better-auth-account-issuer-migration.test.ts +++ b/packages/db/src/better-auth-account-issuer-migration.test.ts @@ -1,17 +1,8 @@ -/** - * The `account.issuer` backfill has to be total. The column lands NOT NULL, so - * a row the UPDATE misses aborts the whole migration and leaves an upgraded - * install with no working auth at all. This suite rewinds the migration, - * seeds the pre-upgrade rows an existing deployment would have, and re-applies - * it — a backfill narrowed to one provider fails here. - */ - -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { getTableConfig } from "drizzle-orm/pg-core"; import postgres from "postgres"; import { afterEach, describe, expect, it } from "vitest"; -import { applyPendingMigrations } from "./client.js"; import { authAccounts } from "./schema/auth.js"; import { getEmbeddedPostgresTestSupport, @@ -21,10 +12,6 @@ import { const MIGRATION_FILE = "0230_better_auth_account_issuer.sql"; const UNIQUE_INDEX = "account_issuer_account_id_uq"; -// Better Auth's own issuer helpers: `createLocalAccountIssuer("credential")` -// for email/password and `createOAuthAccountIssuer(providerId)` for a social -// provider that declares no issuer of its own. Sign-in looks the credential -// account up by this exact value. const CREDENTIAL_ISSUER = "local:credential"; const OAUTH_ISSUER_PREFIX = "local:oauth:"; @@ -32,33 +19,32 @@ const cleanups: Array<() => Promise> = []; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -async function migrationHash() { - const content = await fs.promises.readFile( - new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), - "utf8", - ); - return createHash("sha256").update(content).digest("hex"); +async function applySqlMigration(sql: postgres.Sql, file: string) { + const content = await fs.promises.readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8"); + await sql.begin(async (transaction) => { + for (const statement of content.split("--> statement-breakpoint")) { + if (statement.trim()) await transaction.unsafe(statement); + } + }); } describe("account issuer schema", () => { - it("declares the column Better Auth requires and the key it indexes on", () => { + it("keeps issuer nullable and identifies accounts by provider and account ID", () => { const config = getTableConfig(authAccounts); const issuer = config.columns.find((column) => column.name === "issuer"); expect(issuer).toBeDefined(); - expect(issuer?.notNull).toBe(true); + expect(issuer?.notNull).toBe(false); - // Better Auth declares `(issuer, accountId)` unique on the account model - // and resolves accounts by that pair. - const unique = config.indexes.find((index) => index.config.name === UNIQUE_INDEX); + const unique = config.indexes.find((index) => index.config.name === "account_provider_account_id_uq"); expect(unique?.config.unique).toBe(true); expect(unique?.config.columns.map((column) => (column as { name: string }).name)).toEqual([ - "issuer", + "provider_id", "account_id", ]); }); }); -describeEmbeddedPostgres("account issuer migration", () => { +describeEmbeddedPostgres("historical issuer migration and patched provider identity upgrade", () => { // Reverse registration order, one at a time. The raw `postgres` client is not // registered with the module's client registry, so the cluster teardown does // not close it. Stopping the cluster while that client is still draining kills @@ -75,8 +61,7 @@ describeEmbeddedPostgres("account issuer migration", () => { const sql = postgres(database.connectionString, { max: 1 }); cleanups.push(async () => sql.end()); - // Rewind to the pre-upgrade shape: no issuer column, no unique index. - await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + await sql`DROP INDEX IF EXISTS "account_provider_account_id_uq"`; await sql`DROP INDEX IF EXISTS ${sql(UNIQUE_INDEX)}`; await sql`ALTER TABLE "account" DROP COLUMN IF EXISTS "issuer"`; @@ -101,7 +86,7 @@ describeEmbeddedPostgres("account issuer migration", () => { (${googleAccountId}, 'google-subject-1', 'google', ${socialUserId}, NULL, now(), now()) `; - await applyPendingMigrations(database.connectionString); + await applySqlMigration(sql, MIGRATION_FILE); const rows = await sql<{ id: string; issuer: string }[]>` SELECT "id", "issuer" FROM "account" ORDER BY "id" @@ -133,5 +118,50 @@ describeEmbeddedPostgres("account issuer migration", () => { VALUES (${`account-${randomUUID()}`}, ${CREDENTIAL_ISSUER}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now()) `, ).rejects.toMatchObject({ code: "23505", constraint_name: UNIQUE_INDEX }); + + await applySqlMigration(sql, "0465_better_auth_account_identity.sql"); + const preserved = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`; + expect(preserved).toEqual(rows); + const [nullable] = await sql` + SELECT "is_nullable" FROM "information_schema"."columns" + WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer' + `; + expect(nullable?.is_nullable).toBe("YES"); + await expect(sql` + INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now()) + `).rejects.toMatchObject({ code: "23505", constraint_name: "account_provider_account_id_uq" }); + await sql` + INSERT INTO "account" ("id", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, 'new-subject', 'credential', ${credentialUserId}, now(), now()) + `; + }, 30_000); +}); + +describeEmbeddedPostgres("provider identity migration with duplicate legacy keys", () => { + it("rejects ambiguous provider/account keys without changing legacy rows", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-account-duplicates-"); + const sql = postgres(database.connectionString, { max: 1 }); + try { + await sql`DROP INDEX "account_provider_account_id_uq"`; + await sql`ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL`; + await sql`CREATE UNIQUE INDEX "account_issuer_account_id_uq" ON "account" ("issuer", "account_id")`; + const userId = randomUUID(); + await sql`INSERT INTO "user" ("id", "name", "email", "created_at", "updated_at") + VALUES (${userId}, 'Legacy User', 'legacy@example.com', now(), now())`; + await sql`INSERT INTO "account" ("id", "issuer", "account_id", "provider_id", "user_id", "created_at", "updated_at") + VALUES (${randomUUID()}, 'issuer-a', 'same-subject', 'credential', ${userId}, now(), now()), + (${randomUUID()}, 'issuer-b', 'same-subject', 'credential', ${userId}, now(), now())`; + const before = await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`; + await expect(applySqlMigration(sql, "0465_better_auth_account_identity.sql")) + .rejects.toMatchObject({ code: "23505" }); + expect(await sql`SELECT "id", "issuer" FROM "account" ORDER BY "id"`).toEqual(before); + const [column] = await sql`SELECT "is_nullable" FROM "information_schema"."columns" + WHERE "table_schema" = 'public' AND "table_name" = 'account' AND "column_name" = 'issuer'`; + expect(column?.is_nullable).toBe("NO"); + } finally { + await sql.end(); + await database.cleanup(); + } }, 30_000); }); diff --git a/packages/db/src/migrations/0465_better_auth_account_identity.sql b/packages/db/src/migrations/0465_better_auth_account_identity.sql new file mode 100644 index 00000000000..5145e3b237f --- /dev/null +++ b/packages/db/src/migrations/0465_better_auth_account_identity.sql @@ -0,0 +1,3 @@ +DROP INDEX "account_issuer_account_id_uq";--> statement-breakpoint +ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "account_provider_account_id_uq" ON "account" USING btree ("provider_id","account_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0460_snapshot.json b/packages/db/src/migrations/meta/0465_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0460_snapshot.json rename to packages/db/src/migrations/meta/0465_snapshot.json index 38297d445c0..951e3c6deea 100644 --- a/packages/db/src/migrations/meta/0460_snapshot.json +++ b/packages/db/src/migrations/meta/0465_snapshot.json @@ -1,6 +1,6 @@ { - "id": "f93ccf18-7e86-4788-be77-b17430c86b2f", - "prevId": "e070677d-cfb8-4438-847c-29c4da7ca8c5", + "id": "38dbf2b9-da26-4c3a-b813-f37d0c19cba2", + "prevId": "e2a678da-05cc-4316-a40e-d85a69be01e3", "version": "7", "dialect": "postgresql", "tables": { @@ -6233,7 +6233,7 @@ "name": "issuer", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "account_id": { "name": "account_id", @@ -6309,11 +6309,11 @@ } }, "indexes": { - "account_issuer_account_id_uq": { - "name": "account_issuer_account_id_uq", + "account_provider_account_id_uq": { + "name": "account_provider_account_id_uq", "columns": [ { - "expression": "issuer", + "expression": "provider_id", "isExpression": false, "asc": true, "nulls": "last" @@ -43580,6 +43580,28 @@ "method": "btree", "with": {} }, + "issues_company_legacy_origin_run_idx": { + "name": "issues_company_legacy_origin_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"issues\".\"origin_issue_id\" is null and \"issues\".\"origin_run_id\" is not null", + "concurrently": true, + "method": "btree", + "with": {} + }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", "columns": [ @@ -49085,6 +49107,93 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.plugin_lifecycle_resource_cursors": { + "name": "plugin_lifecycle_resource_cursors", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_through_id": { + "name": "acknowledged_through_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk": { + "name": "plugin_lifecycle_resource_cursors_plugin_id_plugins_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_lifecycle_resource_cursors_company_id_companies_id_fk": { + "name": "plugin_lifecycle_resource_cursors_company_id_companies_id_fk", + "tableFrom": "plugin_lifecycle_resource_cursors", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_lifecycle_resource_cursors_pk": { + "name": "plugin_lifecycle_resource_cursors_pk", + "columns": [ + "plugin_id", + "company_id", + "resource_type", + "resource_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.plugin_logs": { "name": "plugin_logs", "schema": "", @@ -50949,6 +51058,12 @@ "primaryKey": false, "notNull": false }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "git_authorization_grant_id": { "name": "git_authorization_grant_id", "type": "uuid", @@ -51100,6 +51215,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "projects_environment_id_environments_id_fk": { + "name": "projects_environment_id_environments_id_fk", + "tableFrom": "projects", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, "projects_git_authorization_grant_id_connection_grants_id_fk": { "name": "projects_git_authorization_grant_id_connection_grants_id_fk", "tableFrom": "projects", @@ -59494,6 +59622,13 @@ "notNull": true, "default": "''" }, + "pending_reveal": { + "name": "pending_reveal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "allowed_actions": { "name": "allowed_actions", "type": "jsonb", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 1deaa2d7742..efa85154e34 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -3242,6 +3242,13 @@ "when": 1794346237220, "tag": "0464_project_environment", "breakpoints": true + }, + { + "idx": 465, + "version": "7", + "when": 1794400732201, + "tag": "0465_better_auth_account_identity", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 1e82b8a1256..4679accedbb 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -21,25 +21,11 @@ export const authSessions = pgTable("session", { userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), }); -/** - * Better Auth's `account` model. Every column here has to exist for the - * Drizzle adapter to accept the table at all: the adapter validates the - * model's fields against the schema on each write and throws - * `The field "" does not exist in the "account" Drizzle schema` - * when one is missing, which surfaces as a 500 on sign-up. - * - * `issuer` is the account namespace Better Auth 1.7 added. It is - * `local:credential` for email/password accounts and - * `local:oauth:` for social accounts that do not declare an - * issuer of their own, and it pairs with `account_id` as the stable - * provider-side key — hence the unique index, which mirrors the - * `(issuer, accountId)` unique index Better Auth declares on the model. - */ export const authAccounts = pgTable( "account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), + issuer: text("issuer"), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), @@ -54,7 +40,7 @@ export const authAccounts = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }, (table) => ({ - issuerAccountIdUq: uniqueIndex("account_issuer_account_id_uq").on(table.issuer, table.accountId), + providerAccountIdUq: uniqueIndex("account_provider_account_id_uq").on(table.providerId, table.accountId), }), ); diff --git a/server/package.json b/server/package.json index f6d4ab6491c..24877a9bd28 100644 --- a/server/package.json +++ b/server/package.json @@ -90,7 +90,7 @@ "ai": "7.0.130", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "better-auth": "1.7.2", + "better-auth": "1.7.16", "chat": "4.39.0", "chokidar": "^5.0.0", "compression": "^1.8.2", diff --git a/server/src/__tests__/better-auth-credential-signup.integration.test.ts b/server/src/__tests__/better-auth-credential-signup.integration.test.ts index aaf46716561..f4fd9643e71 100644 --- a/server/src/__tests__/better-auth-credential-signup.integration.test.ts +++ b/server/src/__tests__/better-auth-credential-signup.integration.test.ts @@ -28,11 +28,6 @@ const ORIGIN = "http://127.0.0.1:41999"; const EMAIL = "founder@example.com"; const PASSWORD = "correct-horse-battery-staple"; -// The issuer Better Auth stamps on an email/password account: -// `createLocalAccountIssuer("credential")`. Sign-in matches on it, so a row -// written with anything else is a row nobody can sign in as. -const CREDENTIAL_ISSUER = "local:credential"; - const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -107,7 +102,7 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema expect(accounts).toHaveLength(1); expect(accounts[0]).toMatchObject({ providerId: "credential", - issuer: CREDENTIAL_ISSUER, + issuer: null, }); expect(accounts[0]?.password).toBeTruthy(); @@ -119,5 +114,17 @@ describeEmbeddedPostgres("Better Auth credential sign-up against the real schema expect(signIn.status).toBe(200); expect(signIn.body?.user?.email).toBe(EMAIL); expect(sessionCookies(signIn).length).toBeGreaterThan(0); + + await db.update(authAccounts).set({ issuer: "local:credential" }); + const legacySignIn = await request(app) + .post("/api/auth/sign-in/email") + .set("origin", ORIGIN) + .send({ email: EMAIL, password: PASSWORD }); + expect(legacySignIn.status).toBe(200); + expect(legacySignIn.body?.user?.email).toBe(EMAIL); + + await expect(db.insert(authAccounts).values({ ...accounts[0]!, id: "duplicate-credential" })) + .rejects.toMatchObject({ cause: { code: "23505", constraint_name: "account_provider_account_id_uq" } }); + await db.insert(authAccounts).values({ ...accounts[0]!, id: "other-provider", providerId: "other" }); }); }); From a4e89288ec0f2c67fc900b4a0f130e330d196eaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:28:26 +0000 Subject: [PATCH 3/5] chore(lockfile): refresh pnpm lockfiles (#20824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip uses a dependency lock for reproducible builds. > - Dependency changes on master need matching lock entries. > - Paid eval reports also pin a reviewed offline correction. > - Reports must install before paid model work starts. > - This automated PR keeps the lock and report proof together. ## Linked Issues or Issue Description **What happened?** Dependency manifests on master changed after the last committed lock refresh. **Expected behavior** Frozen installs must match those manifests. Eval reports must retain matching offline lock proof. **Steps to reproduce** Run a frozen install before and after this lock refresh. ## What Changed - Refresh the dependency lock from the manifests on master. - Refresh the sandbox provider lockfiles from their manifests on master. - Include the matching offline report correction and hashes. ## Verification - Pinned pnpm resolution completed with lifecycle scripts disabled. - The report proof refresh accepted the reviewed committed baseline. - The workflow rejected changes outside the lock and report proof files. ## Risks The lockfile bot merges this PR without CI or review when every added package version is at least 3 days old on npm. Otherwise the PR waits for a maintainer, and the daily refresh tries again. The bot merges only the exact commit this workflow generated from manifests already reviewed on master. Unknown report lock baselines remain blocked. This update changes no permissions or paid enable gates. ## Model Used None — generated by pnpm and the deterministic report-lock updater. ## Checklist - [x] Thinking path and problem description included - [x] Generated lock and matching report proof included - [x] Release-age gate decides between bot merge and maintainer review Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 192 +++++++------- tests/runner-e2e/report-lock.json | 6 +- tests/runner-e2e/report-lock.patch | 387 ++++++++++++++++++++++++++--- 3 files changed, 442 insertions(+), 143 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d825f9785f..d24edac1c10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1015,7 +1015,7 @@ importers: version: 2.2.0(@grpc/grpc-js@1.14.5)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17) '@vercel/connect': specifier: 2.3.3 - version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) + version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) acorn: specifier: 8.18.0 version: 8.18.0 @@ -1032,8 +1032,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) better-auth: - specifier: 1.7.2 - version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + specifier: 1.7.16 + version: 1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) chat: specifier: 4.39.0 version: 4.39.0(ai@7.0.130(zod@4.6.7))(zod@4.6.7) @@ -1804,64 +1804,61 @@ packages: '@types/react': optional: true - '@better-auth/core@1.7.2': - resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} + '@better-auth/core@1.7.16': + resolution: {integrity: sha512-dwVAtEAEpjPsCV/TqOKRHreWyuiqvja+TnTTAuVbi3P0afM0+b1+ga+RTXNjVE86Q4HRwRg45Ek3txFrY8/1DA==} peerDependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@cloudflare/workers-types': '>=4' + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.3 '@opentelemetry/api': ^1.9.0 - better-call: 1.4.0 + better-call: 1.4.5 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.7.2': - resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} + '@better-auth/drizzle-adapter@1.7.16': + resolution: {integrity: sha512-8cgtcWkCmJal+ecrl681Y+SKvMDy5Vg2ydCOPE7X8Mcx1Ud7Mqve3pASG0I2JWMJtrge1m8YbpNmCxhVGh+gpQ==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.7.2': - resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} + '@better-auth/kysely-adapter@1.7.16': + resolution: {integrity: sha512-MkLZwHxhYbVr1QGGDJDZXMT1g54Q5yucDMdLGaz3Y5P8ERRfaqVEe+3MCVCb8XxJ7Rh+VNJKUix4VOBq7vXimg==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.7.2': - resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} + '@better-auth/memory-adapter@1.7.16': + resolution: {integrity: sha512-b0uUE3mhQJDu2QqLDN3XVHhXUhpr6zK+UhjL7IdNteTJJaAMhCcXfpLc7+kpOcNVEEBdRiFXBwsEtxRjcB3rLQ==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 - '@better-auth/mongo-adapter@1.7.2': - resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} + '@better-auth/mongo-adapter@1.7.16': + resolution: {integrity: sha512-uZG9DhCHv7QcF8HtEIgYEL7Lawu0/1Pcp6ABe4ImYQdLV3HNRaAuQU/PxVxFQFAMSpnCVi4Rwg8+Qk8qVn2YMg==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.7.2': - resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} + '@better-auth/prisma-adapter@1.7.16': + resolution: {integrity: sha512-rSVMa4sWxvEURqVR+o4SZvq6dosCvD58nLw2LOw7woz791/CKOMxhrqrjiHLLzx+OfzGO0Ak9x7lrFTUwYO/KQ==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -1870,21 +1867,18 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.7.2': - resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} + '@better-auth/telemetry@1.7.16': + resolution: {integrity: sha512-mwQC0xB8KO636CjBxMZCNgglaIwh18DOemBJyNCy7DBOngkjPdNggPWYB8BpJ8wMqcn3SaKchdWZrNNqLRZV4Q==} peerDependencies: - '@better-auth/core': ^1.7.2 - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': ^1.7.16 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.3 - '@better-auth/utils@0.4.2': - resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-auth/utils@0.5.1': + resolution: {integrity: sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==} - '@better-auth/utils@0.5.0': - resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} - - '@better-fetch/fetch@1.3.1': - resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@better-fetch/fetch@1.3.3': + resolution: {integrity: sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==} '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -6058,15 +6052,14 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - better-auth@1.7.2: - resolution: {integrity: sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==} + better-auth@1.7.16: + resolution: {integrity: sha512-ur6xj8jeUC9lcH8ni9LrwB4FiFMPDTWFJmb89yZ2SHsFSUobYgGMFsMq6aOorlVtqRr9ckTemRunCR6vzsO65Q==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 + '@sveltejs/kit': ^2.0.0 || ^3.0.0 '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 @@ -6078,7 +6071,7 @@ packages: react-dom: ^19.2.8 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': @@ -6091,8 +6084,6 @@ packages: optional: true '@tanstack/solid-start': optional: true - better-sqlite3: - optional: true drizzle-kit: optional: true drizzle-orm: @@ -6120,8 +6111,8 @@ packages: vue: optional: true - better-call@1.4.0: - resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + better-call@1.4.5: + resolution: {integrity: sha512-GVOWfn3pjEfUXn6GrfGf2h7tPAqj1oMLFBWBEswJrSkDKcLk6TIcjV0zexibcZZ0c5N1mkLMsPFopXWzWj/gDQ==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -7301,9 +7292,6 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} - jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} @@ -9938,64 +9926,59 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)': + '@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.43.0 + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.6.7) - jose: 6.2.10 + better-call: 1.4.5(zod@4.6.7) + jose: 6.2.12 kysely: 0.29.5 nanostores: 1.5.3 zod: 4.6.7 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))': + '@better-auth/drizzle-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 optionalDependencies: drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)) - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 optionalDependencies: kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 - '@better-auth/utils@0.4.2': + '@better-auth/utils@0.5.1': dependencies: '@noble/hashes': 2.4.0 - '@better-auth/utils@0.5.0': - dependencies: - '@noble/hashes': 2.4.0 - - '@better-fetch/fetch@1.3.1': {} + '@better-fetch/fetch@1.3.3': {} '@braintree/sanitize-url@7.1.2': {} @@ -13803,14 +13786,14 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': + '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': dependencies: '@vercel/oidc': 3.8.9 optionalDependencies: '@chat-adapter/slack': 4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.7) ai: 7.0.130(zod@4.6.7) - better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + better-auth: 1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) '@vercel/oidc@3.2.0': {} @@ -14079,22 +14062,22 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): + better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda))) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda))) + '@better-auth/kysely-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/mongo-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/prisma-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) + '@better-auth/telemetry': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3) + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 '@noble/ciphers': 2.4.0 '@noble/hashes': 2.4.0 - better-call: 1.4.0(zod@4.6.7) + better-call: 1.4.5(zod@4.6.7) defu: 6.1.7 - jose: 6.2.10 + jose: 6.2.12 kysely: 0.29.5 nanostores: 1.5.3 zod: 4.6.7 @@ -14106,13 +14089,12 @@ snapshots: react-dom: 19.2.8(react@19.2.8) vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) transitivePeerDependencies: - - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.6.7): + better-call@1.4.5(zod@4.6.7): dependencies: - '@better-auth/utils': 0.5.0 - '@better-fetch/fetch': 1.3.1 + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: @@ -15327,8 +15309,6 @@ snapshots: jose@5.10.0: {} - jose@6.2.10: {} - jose@6.2.12: {} joycon@3.1.1: {} diff --git a/tests/runner-e2e/report-lock.json b/tests/runner-e2e/report-lock.json index aee216eede7..407c60b8ed8 100644 --- a/tests/runner-e2e/report-lock.json +++ b/tests/runner-e2e/report-lock.json @@ -1,6 +1,6 @@ { - "inputSha256": "2360d5e93876ce3cb6e810257d92d669ac2b109027b0e01af36f2048861648c0", - "outputSha256": "b32083066ce89ba9a570a2927b3c4c8385294a5f04efcbae462db82b2e8c4455", - "patchSha256": "8da9b1447b76f9b105d4fce42824f80d25c3f6f3b61f544331892fa9509c0f09", + "inputSha256": "b32083066ce89ba9a570a2927b3c4c8385294a5f04efcbae462db82b2e8c4455", + "outputSha256": "17625672b08d67bc1d69fd452dfd245508cafe38315a0eedb502ac27cb97e828", + "patchSha256": "ea5842b579c1b36b85de1bb30de4b6eb911a1e7faed4595fb37e9b73dddd54a5", "basis": "Generated with the trusted master lockfile refresh. Review this offline correction and its hashes with the dependency changes." } diff --git a/tests/runner-e2e/report-lock.patch b/tests/runner-e2e/report-lock.patch index 42d96996eb6..195f35bdfee 100644 --- a/tests/runner-e2e/report-lock.patch +++ b/tests/runner-e2e/report-lock.patch @@ -1,54 +1,373 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml -index 851e65af..6d825f97 100644 +index 6d825f97..d24edac1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml -@@ -28,7 +28,7 @@ patchedDependencies: - hash: x25bk7vvljpm57viwan5qoaddi - path: patches/@chat-adapter__github@4.39.0.patch - '@chat-adapter/slack@4.39.0': -- hash: 226jj24akljccsfbw7bcxujz4u -+ hash: eskb5kxp7eud5v3ostluhynfoa - path: patches/@chat-adapter__slack@4.39.0.patch - '@chat-adapter/teams@4.39.0': - hash: jfm44m5wrw3jrn2jijafzk7s5a -@@ -934,7 +934,7 @@ importers: - version: 4.39.0(patch_hash=x25bk7vvljpm57viwan5qoaddi)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) - '@chat-adapter/slack': - specifier: 4.39.0 -- version: 4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) -+ version: 4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) - '@chat-adapter/teams': - specifier: 4.39.0 - version: 4.39.0(patch_hash=jfm44m5wrw3jrn2jijafzk7s5a)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) @@ -1015,7 +1015,7 @@ importers: version: 2.2.0(@grpc/grpc-js@1.14.5)(nice-grpc-common@2.0.4)(nice-grpc@2.1.17) '@vercel/connect': specifier: 2.3.3 -- version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) -+ version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) +- version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) ++ version: 2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))) acorn: specifier: 8.18.0 version: 8.18.0 -@@ -10043,7 +10043,7 @@ snapshots: - - workflow - - zod +@@ -1032,8 +1032,8 @@ importers: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + better-auth: +- specifier: 1.7.2 +- version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) ++ specifier: 1.7.16 ++ version: 1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + chat: + specifier: 4.39.0 + version: 4.39.0(ai@7.0.130(zod@4.6.7))(zod@4.6.7) +@@ -1804,64 +1804,61 @@ packages: + '@types/react': + optional: true -- '@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.6.7))(zod@4.6.7)': -+ '@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7)': +- '@better-auth/core@1.7.2': +- resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} ++ '@better-auth/core@1.7.16': ++ resolution: {integrity: sha512-dwVAtEAEpjPsCV/TqOKRHreWyuiqvja+TnTTAuVbi3P0afM0+b1+ga+RTXNjVE86Q4HRwRg45Ek3txFrY8/1DA==} + peerDependencies: +- '@better-auth/utils': 0.4.2 +- '@better-fetch/fetch': 1.3.1 +- '@cloudflare/workers-types': '>=4' ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 ++ '@better-fetch/fetch': 1.3.3 + '@opentelemetry/api': ^1.9.0 +- better-call: 1.4.0 ++ better-call: 1.4.5 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: +- '@cloudflare/workers-types': +- optional: true + '@opentelemetry/api': + optional: true + +- '@better-auth/drizzle-adapter@1.7.2': +- resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} ++ '@better-auth/drizzle-adapter@1.7.16': ++ resolution: {integrity: sha512-8cgtcWkCmJal+ecrl681Y+SKvMDy5Vg2ydCOPE7X8Mcx1Ud7Mqve3pASG0I2JWMJtrge1m8YbpNmCxhVGh+gpQ==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + +- '@better-auth/kysely-adapter@1.7.2': +- resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} ++ '@better-auth/kysely-adapter@1.7.16': ++ resolution: {integrity: sha512-MkLZwHxhYbVr1QGGDJDZXMT1g54Q5yucDMdLGaz3Y5P8ERRfaqVEe+3MCVCb8XxJ7Rh+VNJKUix4VOBq7vXimg==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + +- '@better-auth/memory-adapter@1.7.2': +- resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} ++ '@better-auth/memory-adapter@1.7.16': ++ resolution: {integrity: sha512-b0uUE3mhQJDu2QqLDN3XVHhXUhpr6zK+UhjL7IdNteTJJaAMhCcXfpLc7+kpOcNVEEBdRiFXBwsEtxRjcB3rLQ==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 + +- '@better-auth/mongo-adapter@1.7.2': +- resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} ++ '@better-auth/mongo-adapter@1.7.16': ++ resolution: {integrity: sha512-uZG9DhCHv7QcF8HtEIgYEL7Lawu0/1Pcp6ABe4ImYQdLV3HNRaAuQU/PxVxFQFAMSpnCVi4Rwg8+Qk8qVn2YMg==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + +- '@better-auth/prisma-adapter@1.7.2': +- resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} ++ '@better-auth/prisma-adapter@1.7.16': ++ resolution: {integrity: sha512-rSVMa4sWxvEURqVR+o4SZvq6dosCvD58nLw2LOw7woz791/CKOMxhrqrjiHLLzx+OfzGO0Ak9x7lrFTUwYO/KQ==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: +@@ -1870,21 +1867,18 @@ packages: + prisma: + optional: true + +- '@better-auth/telemetry@1.7.2': +- resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} ++ '@better-auth/telemetry@1.7.16': ++ resolution: {integrity: sha512-mwQC0xB8KO636CjBxMZCNgglaIwh18DOemBJyNCy7DBOngkjPdNggPWYB8BpJ8wMqcn3SaKchdWZrNNqLRZV4Q==} + peerDependencies: +- '@better-auth/core': ^1.7.2 +- '@better-auth/utils': 0.4.2 +- '@better-fetch/fetch': 1.3.1 +- +- '@better-auth/utils@0.4.2': +- resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} ++ '@better-auth/core': ^1.7.16 ++ '@better-auth/utils': ^0.4.2 || ^0.5.0 ++ '@better-fetch/fetch': 1.3.3 + +- '@better-auth/utils@0.5.0': +- resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} ++ '@better-auth/utils@0.5.1': ++ resolution: {integrity: sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==} + +- '@better-fetch/fetch@1.3.1': +- resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} ++ '@better-fetch/fetch@1.3.3': ++ resolution: {integrity: sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} +@@ -6058,15 +6052,14 @@ packages: + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + +- better-auth@1.7.2: +- resolution: {integrity: sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==} ++ better-auth@1.7.16: ++ resolution: {integrity: sha512-ur6xj8jeUC9lcH8ni9LrwB4FiFMPDTWFJmb89yZ2SHsFSUobYgGMFsMq6aOorlVtqRr9ckTemRunCR6vzsO65Q==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 +- '@sveltejs/kit': ^2.0.0 ++ '@sveltejs/kit': ^2.0.0 || ^3.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 +- better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 +@@ -6078,7 +6071,7 @@ packages: + react-dom: ^19.2.8 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 +- vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 ++ vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': +@@ -6091,8 +6084,6 @@ packages: + optional: true + '@tanstack/solid-start': + optional: true +- better-sqlite3: +- optional: true + drizzle-kit: + optional: true + drizzle-orm: +@@ -6120,8 +6111,8 @@ packages: + vue: + optional: true + +- better-call@1.4.0: +- resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} ++ better-call@1.4.5: ++ resolution: {integrity: sha512-GVOWfn3pjEfUXn6GrfGf2h7tPAqj1oMLFBWBEswJrSkDKcLk6TIcjV0zexibcZZ0c5N1mkLMsPFopXWzWj/gDQ==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: +@@ -7301,9 +7292,6 @@ packages: + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + +- jose@6.2.10: +- resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} +- + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + +@@ -9938,64 +9926,59 @@ snapshots: + optionalDependencies: + '@types/react': 19.2.18 + +- '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)': ++ '@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: - '@chat-adapter/shared': 4.39.0(ai@7.0.130(zod@4.6.7))(zod@4.6.7) - '@slack/socket-mode': 2.0.7 -@@ -13803,11 +13803,11 @@ snapshots: +- '@better-auth/utils': 0.4.2 +- '@better-fetch/fetch': 1.3.1 +- '@opentelemetry/semantic-conventions': 1.43.0 ++ '@better-auth/utils': 0.5.1 ++ '@better-fetch/fetch': 1.3.3 + '@standard-schema/spec': 1.1.0 +- better-call: 1.4.0(zod@4.6.7) +- jose: 6.2.10 ++ better-call: 1.4.5(zod@4.6.7) ++ jose: 6.2.12 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.6.7 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + +- '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))': ++ '@better-auth/drizzle-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 + optionalDependencies: + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)) + +- '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': ++ '@better-auth/kysely-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5)': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 + optionalDependencies: + kysely: 0.29.5 + +- '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': ++ '@better-auth/memory-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 + +- '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': ++ '@better-auth/mongo-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 + +- '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': ++ '@better-auth/prisma-adapter@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 + +- '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': ++ '@better-auth/telemetry@1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)': + dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/utils': 0.4.2 +- '@better-fetch/fetch': 1.3.1 ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/utils': 0.5.1 ++ '@better-fetch/fetch': 1.3.3 + +- '@better-auth/utils@0.4.2': ++ '@better-auth/utils@0.5.1': + dependencies: + '@noble/hashes': 2.4.0 + +- '@better-auth/utils@0.5.0': +- dependencies: +- '@noble/hashes': 2.4.0 +- +- '@better-fetch/fetch@1.3.1': {} ++ '@better-fetch/fetch@1.3.3': {} + + '@braintree/sanitize-url@7.1.2': {} + +@@ -13803,14 +13786,14 @@ snapshots: dependencies: execa: 5.1.1 -- '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': -+ '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': +- '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': ++ '@vercel/connect@2.3.3(@chat-adapter/slack@4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7))(@modelcontextprotocol/sdk@1.31.0(zod@4.6.7))(ai@7.0.130(zod@4.6.7))(better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))': dependencies: '@vercel/oidc': 3.8.9 optionalDependencies: -- '@chat-adapter/slack': 4.39.0(patch_hash=226jj24akljccsfbw7bcxujz4u)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) -+ '@chat-adapter/slack': 4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) + '@chat-adapter/slack': 4.39.0(patch_hash=eskb5kxp7eud5v3ostluhynfoa)(ai@7.0.130(zod@4.6.7))(zod@4.6.7) '@modelcontextprotocol/sdk': 1.31.0(zod@4.6.7) ai: 7.0.130(zod@4.6.7) - better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) +- better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) ++ better-auth: 1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + + '@vercel/oidc@3.2.0': {} + +@@ -14079,22 +14062,22 @@ snapshots: + + before-after-hook@4.0.0: {} + +- better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): +- dependencies: +- '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) +- '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda))) +- '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) +- '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) +- '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) +- '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) +- '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.7))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) +- '@better-auth/utils': 0.4.2 +- '@better-fetch/fetch': 1.3.1 ++ better-auth@1.7.16(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.11)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda)))(pg@8.18.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): ++ dependencies: ++ '@better-auth/core': 1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) ++ '@better-auth/drizzle-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9(patch_hash=6q4cic6qawywsyeo2spgnjamda))) ++ '@better-auth/kysely-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(kysely@0.29.5) ++ '@better-auth/memory-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) ++ '@better-auth/mongo-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) ++ '@better-auth/prisma-adapter': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1) ++ '@better-auth/telemetry': 1.7.16(@better-auth/core@1.7.16(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(@opentelemetry/api@1.9.1)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3) ++ '@better-auth/utils': 0.5.1 ++ '@better-fetch/fetch': 1.3.3 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 +- better-call: 1.4.0(zod@4.6.7) ++ better-call: 1.4.5(zod@4.6.7) + defu: 6.1.7 +- jose: 6.2.10 ++ jose: 6.2.12 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.6.7 +@@ -14106,13 +14089,12 @@ snapshots: + react-dom: 19.2.8(react@19.2.8) + vitest: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + transitivePeerDependencies: +- - '@cloudflare/workers-types' + - '@opentelemetry/api' + +- better-call@1.4.0(zod@4.6.7): ++ better-call@1.4.5(zod@4.6.7): + dependencies: +- '@better-auth/utils': 0.5.0 +- '@better-fetch/fetch': 1.3.1 ++ '@better-auth/utils': 0.5.1 ++ '@better-fetch/fetch': 1.3.3 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + optionalDependencies: +@@ -15327,8 +15309,6 @@ snapshots: + + jose@5.10.0: {} + +- jose@6.2.10: {} +- + jose@6.2.12: {} + + joycon@3.1.1: {} From 5f382262903f9f373e084f7ed78ed128f769d757 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:39:15 -0600 Subject: [PATCH 4/5] fix(build): report compiler signals in server typecheck layers (#20603) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its development checks help contributors verify changes. > - The server typecheck runs the compiler once for each source layer. > - A compiler child can stop on a signal before it reports type errors. > - The wrapper returned failure without the signal or the affected layer. > - This pull request reports the signal, layer, command, and working directory. > - Contributors can identify an interrupted check from its output. ## Linked Issues or Issue Description **What happened?** The server typecheck returned exit 1 after a layer banner when a compiler child stopped on a signal. The wrapper did not report that signal. Related layer work: #18737, #18738, and #18739. A search of open and closed PRs found no duplicate signal-reporting fix. **Expected behavior** Report the actual termination signal and affected layer. Keep compiler output visible and return failure. Do not infer a memory failure from SIGKILL alone. **Steps to reproduce** Run a controlled compiler child that sends SIGTERM or SIGKILL to itself. Run it through typecheckLayers and the real runTsc subprocess path. The added tests do this without memory pressure or timed waits. **Paperclip version or commit** Reproduced on 7368a0b15c2f. **Deployment mode** Source checkout on Linux with Node v25.9.0. ## What Changed - Print a stderr diagnostic before the existing early return for a compiler signal. - Include the layer, signal, compiler command, and working directory. - Add real subprocess regressions for SIGTERM in below and SIGKILL in cycle. - Check that child stdout and stderr remain visible and later layers stop. - Document the diagnostic and the limit of what a signal proves. ## Verification - Red: the two new subprocess tests failed because stderr omitted the signal and layer. - Green: `cd server && pnpm exec vitest run scripts/typecheck-layers.test.mjs` passed all 47 tests. This includes real compiler success, declaration output, and compiler error diagnostics. - `git diff --check` passed. - `pnpm verify` returned exit 2. The wrapper is a broad input, so CI must supply broader coverage. - Preflight installed dependencies and built shared/plugin-sdk. It could not build Rust outputs because cargo is absent. Rust builds are outside the focused local test scope. - Final head: `fdd68d7e13751573c661e80a285be86f1a1420bc`. - [CI passed](https://github.com/paperclipai/paperclip/actions/runs/42867062133). Typecheck, build, test suites, and all eight E2E shards passed. - [Greptile gave 5/5](https://github.com/paperclipai/paperclip/pull/20603#issuecomment-6774642345) for this head. No actionable findings or open review threads remain. ## Risks - Low risk. Only the existing signal branch gains a diagnostic. Failure propagation and compiler output handling stay the same. - The controlled signal tests require POSIX signal behavior. Local verification used Linux. ## Model Used - OpenAI Codex, based on GPT-6, with reasoning, repository tools, and code execution. The exact deployment model ID is 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 - [x] All Paperclip CI gates are green - [x] 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 --- doc/DEVELOPING.md | 5 ++++ server/scripts/typecheck-layers.mjs | 7 +++++- server/scripts/typecheck-layers.test.mjs | 32 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1023f2f1414..494ef747545 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1443,6 +1443,11 @@ The server typecheck builds only the Runner's TypeScript output. Use `pnpm --filter @paperclipai/paperclip-runner build` when a local check needs the Runner binary. +If a compiler child terminates on a signal, the wrapper reports the signal, +layer, command, and working directory on stderr, then stops with a nonzero exit. +Compiler stdout and stderr remain visible. A signal such as `SIGKILL` alone does +not establish that the process ran out of memory. + `src/__tests__/tsconfig.json` holds the options for the test-tree layers, so an editor and the typecheck report the same errors. Server tests reach Daytona plugin source, but the root install does not install that plugin's dependencies. So this diff --git a/server/scripts/typecheck-layers.mjs b/server/scripts/typecheck-layers.mjs index 7791e3671f6..ea775d40e67 100644 --- a/server/scripts/typecheck-layers.mjs +++ b/server/scripts/typecheck-layers.mjs @@ -288,7 +288,12 @@ export function typecheckLayers({ const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`); const { status, signal } = run(["-p", config], { cwd: serverRoot }); // A killed process leaves no declarations, so later layers would only add noise. - if (signal) return status || 1; + if (signal) { + console.error( + `typecheck:layers: ${layer.name}: compiler terminated by ${signal} (tsc -p ${config}, cwd: ${serverRoot})`, + ); + return status || 1; + } // Later layers still run after type errors. Each file is checked in one // layer, so this reports everything a single tsc run would. if (status !== 0) exitCode = status; diff --git a/server/scripts/typecheck-layers.test.mjs b/server/scripts/typecheck-layers.test.mjs index 97ee651b6ce..8239fea0c4d 100644 --- a/server/scripts/typecheck-layers.test.mjs +++ b/server/scripts/typecheck-layers.test.mjs @@ -319,6 +319,38 @@ describe("typecheckLayers", () => { expect(status).toBe(1); expect(configs).toHaveLength(1); }); + + it.each([ + ["SIGTERM", "below"], + ["SIGKILL", "cycle"], + ])("reports %s and the affected %s layer from a real compiler child", (signal, layer) => { + const root = serverFixture(); + const bin = path.join(root, "node_modules", "typescript", "bin"); + mkdirSync(bin, { recursive: true }); + writeFileSync(path.join(bin, "tsc"), ` + const fs = require("node:fs"); + fs.writeSync(1, "compiler stdout\\n"); + fs.writeSync(2, "compiler stderr\\n"); + if (process.argv[3].endsWith("tsconfig.${layer}.json")) { + process.kill(process.pid, ${JSON.stringify(signal)}); + } + `); + const wrapper = new URL("./typecheck-layers.mjs", import.meta.url).href; + + const result = spawnSync(process.execPath, ["--input-type=module", "-e", ` + import { typecheckLayers } from ${JSON.stringify(wrapper)}; + process.exitCode = typecheckLayers({ serverRoot: ${JSON.stringify(root)} }); + `], { encoding: "utf8", timeout: 10_000 }); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stdout).toContain("compiler stdout"); + expect(result.stderr).toContain("compiler stderr"); + expect(result.stderr).toContain(`typecheck:layers: ${layer}: compiler terminated by ${signal}`); + expect(result.stderr).toContain(`tsconfig.${layer}.json`); + expect(result.stdout).not.toContain("typecheck:layers: above"); + }); }); describe("typecheckLayers with the real compiler", () => { From fd1460bb24effa5897d4cc97581f02876d368482 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:47:06 -0600 Subject: [PATCH 5/5] fix(native): continue a governed-wait follow-up in a fresh session when its saved provider state is gone (#20809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The restart chaos harness (`tests/restart-chaos/`) runs real agent tasks and breaks the control plane on purpose, so failures show up before staging users see them > - A census of 48 hours of staging failures found runs whose result save-back failed because the sandbox disk was full (class F). The harness never filled a sandbox disk, so it missed that class > - #20758 made a full-disk save end recovery at once with cause `sandbox_disk_full` and a Retry. Nothing in chaos exercised that path end to end > - A real full disk cannot be made on demand. The owner chose a test-only hook that makes the save report a full disk, not a small disk > - This pull request adds the hook at the save's sandbox boundary and a DF1 fault that arms it, clears it, and grades the result > - The benefit is that every wave can now check the disk-full rest, the Retry offer, and that Retry works after space is freed ## Linked Issues or Issue Description Refs #20758 (product behaviour under test), #20678 (finalization hands a full-disk copy-back to recovery) **What is the problem?** The chaos harness had no fault for a full sandbox disk during result save-back. Staging saw this failure (census class F in `doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`), and chaos could not reproduce it. **What is the expected behaviour?** A wave can schedule DF1. While DF1 is armed, the task's final workspace saves fail as a full disk. When DF1 is cleared, saves succeed again. The task must rest with `sandbox_disk_full` and a Retry, and that Retry must finish the task. **Why does it matter?** Without a fault, a regression in the disk-full path (no Retry, a wrong cause, or a Retry that does not work) reaches staging users before chaos sees it. ## What Changed - `server/src/services/sandbox-disk-full-fault.ts` (new, test-only): `sandboxDiskFullFaultTarget` wraps the target of a final save only when the harness armed that save. The capture commands then exit 1 with the exact message the in-sandbox script writes on ENOSPC, and the provider download fails with `No space left on device`. The gate follows `@paperclipai/shared/crash-points`: `PAPERCLIP_DISK_FULL_FAULT_CONTROL` is read once at import and ignored under `NODE_ENV=production`. Disarmed, the hook is one comparison and returns the save's own target. - `server/src/services/remote-workspaces.ts`: the capture call in `saveRemoteWorkspace` passes its target through the hook. No other product change. `workspace-recovery.ts` is not touched. - `tests/restart-chaos/disk-full-faults.ts` (new): the DF1 kind, the control-file seam (atomic writes), the rest evidence (`activeRecoveryAction.evidence.systemRecovery.failure.cause.label`), the `disk_full_fault` T check, and the DF1 R3 rule (one expected Retry that must finish the task). - Harness wiring: `--faults DF1` (refused without `--env daytona`, with a storm, or with PV faults), no scheduled restart, arm at launch, clear at the first rest just before the Retry click, receipts in `/disk-full-faults.jsonl`, and ledger fields `diskFull: { faultedSaves, restedDiskFull, retryOffered, retryWorked }`. - Docs: a "Sandbox disk full (DF1)" section in `tests/restart-chaos/README.md`, and the census plan marks DF1 as built. ## Verification - `cd server && pnpm exec vitest run src/services/sandbox-disk-full-fault.test.ts`: the gate is off by default and under `NODE_ENV=production`, the save's own target comes back when disarmed, only a listed final save is faulted, the product capture turns it into the named disk-full failure, and clearing the file disarms the next save. - `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts tests/restart-chaos/disk-full-faults.test.ts tests/restart-chaos/grader.test.ts tests/restart-chaos/scheduler.test.ts tests/restart-chaos/provider-faults.test.ts`: planning, the control file, and grading. - `node scripts/typecheck-test-projects.mjs tests/restart-chaos` and the server `tsc --noEmit`: clean. - Live wave: a Daytona DF1 wave is running. Its result is added here before merge. ## Risks - Low risk for production. The hook reads its variable once at startup and is ignored under `NODE_ENV=production`. Without the variable it does one comparison and returns the same target object. - The hook fails every sandbox command of an armed capture, including the read-only fallback's inventory. A real full disk can let that inventory succeed and fail later at the download. The outcome is the same (`sandbox_disk_full`), but the fallback's inventory step is not exercised under DF1. - DF1 runs only with `--env daytona`. A local run has no sandbox workspace save, so the harness refuses DF1 there. ## Model Used - Claude Opus 5.5 (Anthropic, model ID `claude-opus-5-5`), in Claude Code with tool use (shell, file edits, GitHub CLI). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- doc/execution-semantics.md | 18 ++ .../src/backends/harness-driver-backend.ts | 1 + .../backends/native-backend-factory.test.ts | 2 + .../src/contracts/control-plane-port.ts | 7 + .../src/contracts/harness-driver.ts | 4 + .../src/contracts/native-session-backend.ts | 1 + .../codex/codex-app-server-driver-impl.ts | 8 +- .../src/native-session-runtime.test.ts | 166 +++++++++++++++++ .../src/native-session-runtime.ts | 71 ++++++- ...ost-state-continuation.integration.test.ts | 175 ++++++++++++++++++ .../paperclip-control-plane-port.ts | 30 +++ 11 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 server/src/services/native-runtime/native-governed-wait-lost-state-continuation.integration.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 1812b0839ae..1f0004da3a9 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -3029,6 +3029,24 @@ ends with `native_provider_state_incompatible`, and Retry or the next message continues in a fresh provider session in the same workspace. A session with work in flight keeps the unclassified behavior. +A follow-up after a governed wait (`allow_replacement_after_governed_wait`) +normally requires the same provider session. For this one refusal it continues +on its own in a fresh provider session in the same run, without a Retry, when +all of these hold: the driver reports the Runner's typed missing-state refusal +for a session with nothing in flight; the reconciled checkpoint is the untouched +rebind of the previous run (no cursor progress, turn, runtime request, proposed +result, terminal, or governed wait of the follow-up run, and the at-most-once +recovery marker present); and the control plane proves from durable rows, +across every runner instance, that the follow-up run has no provider event, +source-sequenced runner event, tool invocation, or accepted result. The follow-up +has not started a turn, so a fresh session cannot repeat provider work or a tool +side effect; only the earlier conversation context is lost. The fresh session +gets the full task prompt, the interaction responses, the stored fresh-session +handoff, and queued input, as any replacement does, and the run log records the +existing provider session continuity break with the reason. Any other +incompatible state, a control plane without that proof, or a checkpoint saved +without those fields keeps `native_provider_state_incompatible` and its Retry. + Bootstrap retries, exact-checkpoint resumes, and fresh replacement sessions share three total provider attempts, including the original attempt. Linked run IDs, controller restarts, and duplicate wakes do not reset this budget. A resume after a graceful shutdown drain suspended the run for reattachment does not consume an attempt, because the run did not cause that restart. A crash restart (no drain record) consumes an attempt only when the attempt it interrupted spent provider work without progress. This is one rule over recorded outcomes, not over intents. The attempt counts as started only when the provider accepted a turn: a `turn.accepted` event or the provider's own `turn.started`. A startup still marked `beforeSpawn`, a runner that spawned but was never admitted, and a `turn.start` the provider did not accept are not charged. Progress means that an accepted turn then completed work: a tool execution or a completed item other than a usage report. An accepted turn with only usage, a failure, or no events is charged, so a resume that fails each time still uses up the budget. The first restart judges the original execution. Each later restart claim records the run's event-log position, and the next claim judges only the attempt recorded after it; a normal retry in between starts a new attempt (at its `native.process_start_requested`), so earlier work does not count for it. A reattachment adopts a provider that already started, so for it only progress counts. A resume that launches a new runner and that the controller died before starting used no provider work, so the next claim replaces it and the attempt ends at the attempt that resume was to run as. Uncharged restarts stop after 12 controller generations, so a crash loop that never starts the provider, or that makes progress and then crashes the controller each time, is still bounded. Legacy runs follow the same rule: a preparation interrupted before provider work started (`providerWorkStarted: false`) is retried in its own `interrupted_preparation` lane, which never spends the failure retry budget and is bounded at 12 retries. This holds for a hard kill as well as a graceful drain: once the killed controller's recorded process is proven gone, restart recovery fails the run as `process_lost` with the bootstrap certificate and schedules it in that lane. A run saved by an older release without a controller process stamp cannot be proven and stays in the charged lane. Exact-checkpoint resumes retain the 30-second delay. Replacement scheduling and predecessor lineage commit together, with one successor per predecessor and admission through the normal task locks, authorization, pause, approval, and budget gates. A due failed remote retry can retain its provider PID after heartbeat completion diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.ts index dfaee9efff3..e6623853776 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.ts @@ -167,6 +167,7 @@ export class HarnessDriverBackend implements NativeSessionBackend { recovered: false, reason: recovered.reason, ...(recovered.failureClass ? { failureClass: recovered.failureClass } : {}), + ...(recovered.resumeStateUnavailable ? { resumeStateUnavailable: true as const } : {}), }; } try { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 8ccefae294d..6886e845d0a 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -488,6 +488,7 @@ describe("native backend factory", () => { recovered: false, reason: expect.stringContaining("PRP provider resume state is unavailable"), failureClass: PROVIDER_STATE_INCOMPATIBLE, + resumeStateUnavailable: true, }); }); @@ -499,6 +500,7 @@ describe("native backend factory", () => { const recovered = await backend.recoverSession!({ ...snapshot, ...inFlight } as never, { signal: new AbortController().signal }); expect(recovered).toMatchObject({ recovered: false, reason: expect.stringContaining("PRP provider resume state is unavailable") }); expect(recovered).not.toHaveProperty("failureClass"); + expect(recovered).not.toHaveProperty("resumeStateUnavailable"); }); }); diff --git a/packages/paperclip-runner/src/contracts/control-plane-port.ts b/packages/paperclip-runner/src/contracts/control-plane-port.ts index b6e3aa685ea..6b91bfd4492 100644 --- a/packages/paperclip-runner/src/contracts/control-plane-port.ts +++ b/packages/paperclip-runner/src/contracts/control-plane-port.ts @@ -99,4 +99,11 @@ export interface ControlPlanePort { result: NativeRunResult | CompleteControlPlaneRunInput, options?: FinalizeControlPlaneOperationOptions, ): Promise; + /** + * Durable proof, across every runner instance, that this run has recorded no + * provider event, tool invocation, or accepted result. True only when the + * control plane can prove it; a port without this proof keeps every + * recovery refusal unchanged. + */ + provesNoRunProviderWork?(options: FinalizeControlPlaneOperationOptions): Promise; } diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index b8903443c8c..7a9396eac64 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -557,6 +557,10 @@ export interface HarnessSessionRecoveryResult { reason?: string; /** Closed runner class of a deterministic recovery failure. */ failureClass?: RunnerRecoveryFailureClass; + /** Set only with PROVIDER_STATE_INCOMPATIBLE: the Runner proved it holds no + * saved state for this session (NativeRunnerResumeStateUnavailableError) and + * the checkpoint had no turn or runtime request in flight. */ + resumeStateUnavailable?: true; } /** Reads the closed class of an authenticated runner command failure, or of a diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts index 2931aa8b1e5..b8e9d775fec 100644 --- a/packages/paperclip-runner/src/contracts/native-session-backend.ts +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -86,6 +86,7 @@ export interface NativeSessionRecoveryResult { session?: NativeSession; reason?: string; failureClass?: HarnessSessionRecoveryResult["failureClass"]; + resumeStateUnavailable?: HarnessSessionRecoveryResult["resumeStateUnavailable"]; } /** Runner-owned cause: the process was restored, but its active turn was lost. */ diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 834fdf100e8..9b78d7403ce 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -773,13 +773,15 @@ export class CodexAppServerDriver implements HarnessDriver { // resume it here. With nothing in flight, the runtime's recovery policy // decides, as for a moved workspace root. An in-flight session keeps the // unclassified failure. - const failureClass = runnerRecoveryFailureClass(error) - ?? (isRunnerResumeStateUnavailable(error) && !persistedSessionInFlight(snapshot) - ? PROVIDER_STATE_INCOMPATIBLE : undefined); + const classified = runnerRecoveryFailureClass(error); + const resumeStateUnavailable = !classified && isRunnerResumeStateUnavailable(error) + && !persistedSessionInFlight(snapshot); + const failureClass = classified ?? (resumeStateUnavailable ? PROVIDER_STATE_INCOMPATIBLE : undefined); return { recovered: false, reason: redactCodexDiagnostic(String(error)), ...(failureClass ? { failureClass } : {}), + ...(resumeStateUnavailable ? { resumeStateUnavailable: true as const } : {}), }; } finally { cancellation.detach(); diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 0f30e117452..ba31e5da9b8 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -17,6 +17,7 @@ import type { NativeRunIdentity } from "./contracts/types.js"; import type { NativeSession, NativeSessionBackend, + NativeSessionRecoveryResult, PersistedNativeSession, } from "./contracts/native-session-backend.js"; import { @@ -6664,6 +6665,171 @@ describe("executeNativeSession recovery", () => { } }); + // Staging class P after #20757: a follow-up after a governed (approval) wait + // rebinds the previous run's session, but the new sandbox holds no saved + // Runner state for it. The follow-up has not started a turn, so a fresh + // provider session cannot repeat provider work or a tool side effect; only + // the earlier conversation context is lost. The run continues in a fresh + // session with the full task, the answer and the stored handoff. + describe("a governed-wait follow-up whose saved provider state is gone", () => { + const approval = { interactionId: "interaction-approval", kind: "request_confirmation" as const, + response: { outcome: "accepted", note: "APPROVED_BY_BOARD" } }; + const followUpInput = { + ...input, + task: { ...input.task, prompt: "FULL_TASK_CONTEXT\nAPPROVAL_FOLLOW_UP" }, + interactionResponses: [approval], + }; + const reboundCheckpoint = (): PersistedNativeSession => ({ + backendKind: "mock", driverKind: "codex_app_server", sessionId: "driver-lost", identity, + providerSessionId: "provider-lost", providerRecoveryPolicy: "allow_replacement_after_governed_wait", + cursor: null, semanticResult: null, terminal: null, activeTurnId: null, terminalTurns: [], + pendingRuntimeRequests: [], dispositionOnlyRecoveryConsumed: false, dispositionOnlyRecoveryTurnId: null, + lineage: [], + }); + const stateUnavailable: NativeSessionRecoveryResult = { + recovered: false, failureClass: PROVIDER_STATE_INCOMPATIBLE, resumeStateUnavailable: true, + reason: "Error: NativeRunnerResumeStateUnavailableError: PRP provider resume state is unavailable", + }; + function harness(options: { + checkpoint?: PersistedNativeSession; + recovery?: Awaited>>; + proof?: boolean | null | "rejects" | "hangs"; + replay?: PrpEvent[]; + } = {}) { + const startTurn = vi.fn(async () => ({ turnId: "turn-fresh" })); + const replacementSnapshot: PersistedNativeSession = { + ...reboundCheckpoint(), sessionId: "driver-fresh", providerSessionId: "provider-fresh", + }; + const replacementSession: NativeSession = { + identity: () => identity, + capabilities: async () => ({ resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }), + async *events() { yield runnerEvent(1, "turn.completed"); }, + startTurn, + result: async () => ({ result, terminal, turnId: "turn-fresh" }), + snapshot: async () => structuredClone(replacementSnapshot), + close: async () => {}, + }; + const openReplacementSession = vi.fn(async () => replacementSession); + const openSession = vi.fn(async () => { throw new Error("replacement seam must be used"); }); + const recoverSession = vi.fn(async () => options.recovery ?? stateUnavailable); + const backend: NativeSessionBackend = { + descriptor: async () => ({ kind: "mock", name: "governed-follow-up", version: "1", + capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true } }), + openSession, openReplacementSession, recoverSession, + }; + const events: PrpEvent[] = []; + const provesNoRunProviderWork = vi.fn(async ({ signal }: { signal: AbortSignal }) => { + if (options.proof === "rejects") throw new Error("database unavailable"); + if (options.proof === "hangs") return await new Promise((_resolve, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { once: true })); + return options.proof === true; + }); + const openRun = vi.fn(async () => undefined); + const replay = options.replay ?? []; + const controlPlane: ControlPlanePort = { + openRun, + loadSessionCheckpoint: async () => structuredClone(options.checkpoint ?? reboundCheckpoint()), + checkpointSession: async () => {}, + async appendEvent(event) { + events.push(structuredClone(event as PrpEvent)); + return { cursor: events.length, highestContiguousSourceSeq: highestContiguous(events), disposition: "committed" }; + }, + replayEvents: async ({ afterSourceSeq }) => { + const page = replay.filter(event => event.sourceSeq > afterSourceSeq); + return { events: page, highestContiguousSourceSeq: highestContiguous(page) }; + }, + completeRun: async () => {}, + ...(options.proof === null ? {} : { provesNoRunProviderWork }), + }; + const onContinuityBreak = vi.fn(async () => undefined); + const getFreshSessionHandoff = vi.fn(async () => "FRESH_HANDOFF: prior conversation summary"); + const run = () => executeNativeSession({ + input: followUpInput, backend, controlPlane, getFreshSessionHandoff, onContinuityBreak, + runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", + }); + return { run, startTurn, openReplacementSession, openSession, recoverSession, provesNoRunProviderWork, + openRun, onContinuityBreak, getFreshSessionHandoff }; + } + + it("continues in a fresh provider session with the task, the answer and the stored handoff", async () => { + const h = harness({ proof: true }); + await expect(h.run()).resolves.toMatchObject({ providerSessionId: "provider-fresh" }); + expect(h.recoverSession).toHaveBeenCalledOnce(); + expect(h.provesNoRunProviderWork).toHaveBeenCalledOnce(); + expect(h.openReplacementSession).toHaveBeenCalledOnce(); + expect(h.openSession).not.toHaveBeenCalled(); + expect(h.startTurn).toHaveBeenCalledOnce(); + const envelope = JSON.parse(h.startTurn.mock.calls[0]![0].message.text) as { + task: { prompt: string }; interactionResponses: unknown[]; + }; + expect(envelope.task.prompt).toBe("FRESH_HANDOFF: prior conversation summary\n\nFULL_TASK_CONTEXT\nAPPROVAL_FOLLOW_UP"); + expect(envelope.interactionResponses).toEqual([approval]); + expect(h.startTurn.mock.calls[0]![0]).not.toHaveProperty("continuation"); + // The user sees that the earlier conversation context was reset. + expect(h.onContinuityBreak).toHaveBeenCalledWith({ + reason: expect.stringContaining("saved provider state is unavailable, so the follow-up continues in a fresh provider session without the earlier conversation context"), + previousDriverSessionId: "driver-lost", previousProviderSessionId: "provider-lost", + replacementDriverSessionId: "driver-fresh", replacementProviderSessionId: "provider-fresh", + }); + }); + + it.each([ + ["the control plane cannot prove the run started no provider work", { proof: false }], + ["the control plane predates the provider-work proof", { proof: null }], + ["the provider-work proof fails", { proof: "rejects" }], + ["the saved state is incompatible for another reason", { proof: true, recovery: { + recovered: false as const, failureClass: PROVIDER_STATE_INCOMPATIBLE, reason: "saved launch profile changed" } }], + ["the checkpoint requires the same session", { proof: true, + checkpoint: { ...reboundCheckpoint(), providerRecoveryPolicy: "same_session_only" as const } }], + ["this run already completed a turn", { proof: true, + checkpoint: { ...reboundCheckpoint(), cursor: "2", terminalTurns: [{ turnId: "turn-own", fingerprint: "completed" }] } }], + ["this run already advanced the durable cursor", { proof: true, checkpoint: { ...reboundCheckpoint(), cursor: "3" } }], + ["this run already recorded a provider event", { proof: true, replay: [runnerEvent(1, "turn.started")] }], + ["this run already proposed a result", { proof: true, checkpoint: { ...reboundCheckpoint(), semanticResult: result } }], + ["the checkpoint was saved without the at-most-once recovery marker", { proof: true, + checkpoint: (() => { const { dispositionOnlyRecoveryConsumed: _omitted, ...older } = reboundCheckpoint(); return older; })() }], + ] as const)("keeps the named refusal and a working Retry when %s", async (_label, options) => { + const h = harness(options as Parameters[0]); + const failure = await h.run().catch((error: unknown) => error); + expect(failure).toBeInstanceOf(NativeProviderStateIncompatibleError); + expect((failure as NativeProviderStateIncompatibleError).code).toBe("native_provider_state_incompatible"); + expect(h.openReplacementSession).not.toHaveBeenCalled(); + expect(h.openSession).not.toHaveBeenCalled(); + expect(h.openRun).not.toHaveBeenCalled(); + expect(h.startTurn).not.toHaveBeenCalled(); + }); + + it("never replaces a session whose checkpoint records this run's own governed wait", async () => { + const h = harness({ proof: true, checkpoint: { ...reboundCheckpoint(), + governedWait: { sourceEvent: runnerEvent(1, "turn.started"), result: yieldedResult } } }); + await expect(h.run()).rejects.toThrow(); + expect(h.openReplacementSession).not.toHaveBeenCalled(); + expect(h.startTurn).not.toHaveBeenCalled(); + }); + + it("keeps the named refusal when the provider-work proof does not answer in time", async () => { + vi.useFakeTimers(); + try { + const h = harness({ proof: "hangs" }); + const failure = h.run().catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(30_001); + expect(await failure).toBeInstanceOf(NativeProviderStateIncompatibleError); + expect(h.provesNoRunProviderWork).toHaveBeenCalledOnce(); + expect(h.openReplacementSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("stops by name when the follow-up's saved session still owns a runtime request", async () => { + const h = harness({ proof: true, recovery: { recovered: false as const, failureClass: PROVIDER_SIDE_EFFECT_PENDING, + reason: "native_runner_source_runtime_reconciliation_required" } }); + await expect(h.run()).rejects.toBeInstanceOf(NativeProviderSideEffectPendingError); + expect(h.provesNoRunProviderWork).not.toHaveBeenCalled(); + expect(h.openReplacementSession).not.toHaveBeenCalled(); + }); + }); + // A remote workspace epoch was abandoned, so the run was assigned a new // sibling root while its checkpoint still names the old one. A checkpoint // that requires the same session (an unfinished goal) ends with the diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index 7b9d0971362..1d480fb524f 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -18,7 +18,7 @@ import type { NativeSession, NativeSessionBackend, } from "./contracts/native-session-backend.js"; -import type { PersistedNativeSession } from "./contracts/native-session-backend.js"; +import type { NativeSessionRecoveryResult, PersistedNativeSession } from "./contracts/native-session-backend.js"; import { PROVIDER_ATTACH_REFUSED, PROVIDER_SIDE_EFFECT_PENDING, @@ -1917,6 +1917,61 @@ function checkpointCursor(cursor: string | null | undefined): number { return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0; } +/** + * A follow-up after a governed wait normally requires the same provider + * session. When the Runner proved the saved session state is gone, and nothing + * proves this run started provider work of its own, a fresh session cannot + * repeat a turn or a tool side effect: only the earlier conversation context is + * lost. Every other case keeps the named refusal. The reconciled checkpoint must + * be the untouched rebind of the previous run (no cursor progress, turn, + * runtime request, result, terminal or governed wait of this run), and the + * control plane must prove durably, across runner instances, that the run has + * no provider event, tool invocation or accepted result. A control plane + * without that proof, or a checkpoint missing those fields, keeps the refusal. + */ +const GOVERNED_WAIT_PROVIDER_WORK_PROOF_TIMEOUT_MS = 30_000; + +async function governedWaitFollowUpMayStartFresh(input: { + input: NativeExecutionInput; + checkpoint: PersistedNativeSession; + recovery: NativeSessionRecoveryResult; + restartInterruption: boolean; + controlPlane: ControlPlanePort; + timeoutMs: number; +}): Promise { + const { checkpoint, recovery } = input; + if ( + checkpoint.providerRecoveryPolicy !== "allow_replacement_after_governed_wait" || + ("homeMode" in input.input && input.input.homeMode === "preconfigured") || + recovery.failureClass !== PROVIDER_STATE_INCOMPATIBLE || + recovery.resumeStateUnavailable !== true || + input.restartInterruption || + checkpoint.identity.runId !== input.input.binding.runId || + !(checkpoint.cursor === null || checkpoint.cursor === "0") || + checkpoint.activeTurnId !== null || + !Array.isArray(checkpoint.terminalTurns) || checkpoint.terminalTurns.length > 0 || + !Array.isArray(checkpoint.pendingRuntimeRequests) || checkpoint.pendingRuntimeRequests.length > 0 || + checkpoint.semanticResult !== null || + checkpoint.terminal !== null || + checkpoint.governedWait !== undefined || + checkpoint.dispositionOnlyRecoveryConsumed !== false || + !input.controlPlane.provesNoRunProviderWork + ) return false; + const controlPlane = input.controlPlane; + // A proof that fails or expires is no proof: keep the named refusal and its + // Retry rather than surfacing a generic interruption. + const timeoutMs = Math.min(input.timeoutMs, GOVERNED_WAIT_PROVIDER_WORK_PROOF_TIMEOUT_MS); + try { + return await runAbortableOperationWithin({ + timeoutMs, + timeoutMessage: `native session provider-work proof timed out after ${timeoutMs}ms`, + operation: (signal) => controlPlane.provesNoRunProviderWork!({ signal }), + }) === true; + } catch { + return false; + } +} + async function reconcileRecoveryCheckpoint(input: { controlPlane: ControlPlanePort; checkpoint: PersistedNativeSession; @@ -2520,7 +2575,15 @@ export async function executeNativeSession( if (recovery.failureClass === PROVIDER_ATTACH_REFUSED) { throw new NativeProviderStateIncompatibleError(recovery.reason ?? "unknown"); } - if (!replacementAllowed) { + const governedWaitFollowUpLostState = !replacementAllowed && await governedWaitFollowUpMayStartFresh({ + input, + checkpoint: providerRecoveryCheckpoint, + recovery, + restartInterruption: Boolean(checkpointedInterruption), + controlPlane: options.controlPlane, + timeoutMs: recoveryTimeoutMs, + }); + if (!replacementAllowed && !governedWaitFollowUpLostState) { if (recovery.failureClass === PROVIDER_STATE_INCOMPATIBLE) { throw new NativeProviderStateIncompatibleError(recovery.reason ?? "unknown"); } @@ -2529,7 +2592,9 @@ export async function executeNativeSession( ); } continuityBreak = { - reason: recovery.reason ?? "provider session is no longer recoverable", + reason: governedWaitFollowUpLostState + ? `saved provider state is unavailable, so the follow-up continues in a fresh provider session without the earlier conversation context (${recovery.reason ?? "unknown"})` + : recovery.reason ?? "provider session is no longer recoverable", previousDriverSessionId: providerRecoveryCheckpoint.sessionId, previousProviderSessionId: providerRecoveryCheckpoint.providerSessionId ?? null, diff --git a/server/src/services/native-runtime/native-governed-wait-lost-state-continuation.integration.test.ts b/server/src/services/native-runtime/native-governed-wait-lost-state-continuation.integration.test.ts new file mode 100644 index 00000000000..932ba718e59 --- /dev/null +++ b/server/src/services/native-runtime/native-governed-wait-lost-state-continuation.integration.test.ts @@ -0,0 +1,175 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq, sql } from "drizzle-orm"; +import { afterEach, expect, it, vi } from "vitest"; +import { completionContracts, heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, toolInvocations } from "@paperclipai/db"; +import { describeEmbeddedPostgres, nativeRestartFixture } from "../../__tests__/helpers/native-restart.js"; +import { DurablePrpControlPlane, parseNativeExecutionInput, type NativeSessionBackend, type PersistedNativeSession } from "../../vendor/paperclip-runner/index.js"; +import { createNativeControllerRecovery } from "./native-controller-recovery.js"; +import { nativeRunnerCheckpointWriter } from "./native-runner-checkpoint.js"; +import { executePaperclipNativeSession, nativeControllerMigrationDescriptor } from "./native-session-executor.js"; +import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js"; +import { nativeToolContractFingerprintForTarget, rebindNativeSessionCheckpoint } from "./native-session-resume.js"; +import { prepareNativeHeartbeatRun } from "./prepare-native-run.js"; +import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; + +vi.mock("./paperclip-runner-tool-authority.js", () => ({ PaperclipRunnerToolAuthority: class { + readonly binding = {}; + async definitions() { return []; } + close() {} +} })); + +// Staging class P after #20757: run A yielded for an approval, its harness +// backup was never saved, and its sandbox was destroyed. The follow-up run B +// rebinds A's session in a new sandbox whose Runner has no saved state for it. +// B has not started a turn, so a fresh provider session cannot repeat provider +// work or a tool side effect. B continues in that fresh session on its own, +// with no Retry. Any durable sign that B already did provider work keeps the +// permanent named refusal. +describeEmbeddedPostgres("governed-wait follow-up whose saved provider state is gone", () => { + const environment = nativeRestartFixture(); + const homes: string[] = []; + afterEach(async () => { vi.unstubAllEnvs(); for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); + + async function followUpRun() { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "61".repeat(32)); + const f = await environment.seedRun(randomUUID()); + const { companyId, agentId, runtimeRoot } = environment.current; + const home = await mkdtemp(join(tmpdir(), "native-governed-lost-state-")); homes.push(home); + vi.stubEnv("PAPERCLIP_HOME", home); + const [sourceRun] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + const [contract] = await f.db.select().from(completionContracts).where(eq(completionContracts.id, sourceRun!.completionContractId!)); + const execution = parseNativeExecutionInput({ + schema: "paperclip.native-execution-input.v5", executionMode: "default", planningContext: null, + provider: { kind: "codex", model: null, approvalPolicy: "never" }, + binding: { companyId, agentId, issueId: f.issueId, runId: f.runId, executionWorkspaceId: randomUUID() }, + task: { identifier: "GWL-1", title: "Approved work", description: null, prompt: "Continue after the approval.", workMode: "standard" }, + workspace: { cwd: runtimeRoot, repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: f.native.normalizedSessionId, driverKind: "codex_app_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + completionContract: { id: contract!.id, sha256: contract!.canonicalSha256, schemaVersion: contract!.schemaVersion, contract: contract!.contractJson }, + runtimeContext: nativeRuntimeContextFixture(), credentialBindings: [], interactionResponses: [], + }); + const yielded = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "Waiting for approval", + completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + continuation: { kind: "response_wake", summary: "Continue after the answer", idempotencyKey: "fixture-approval" } }; + const snapshot: PersistedNativeSession = { backendKind: "runner", driverKind: "codex_app_server", sessionId: "lost-driver-session", + providerSessionId: "lost-provider-session", identity: { companyId, agentId, issueId: f.issueId, runId: f.runId, sessionId: f.native.normalizedSessionId }, + cursor: "0", activeTurnId: null, pendingRuntimeRequests: [], terminalTurns: [], lineage: [], semanticResult: yielded as never, goal: null }; + const bootId = randomUUID(), leaseOwner = randomUUID(); + await f.db.update(nativeRunFinalizations).set({ controllerBootId: bootId, controllerGeneration: 1, controllerPid: 2_147_483_000, + controllerProcessStartedAt: new Date(Date.now() - 60_000), leaseOwner, leaseExpiresAt: new Date(Date.now() + 60_000) }).where(eq(nativeRunFinalizations.runId, f.runId)); + const core = new DurablePrpControlPlane({ stateDirectory: join(runtimeRoot, randomUUID()), identity: { runId: f.runId, normalizedSessionId: f.native.normalizedSessionId, + runnerInstanceId: f.native.runnerInstanceId, environmentLeaseId: f.native.environmentLeaseId, turnId: f.native.turnId, itemId: f.native.itemId }, + expectedRunnerVersion: "governed-lost-state-fixture", expectedRunnerDigest: `sha256:${"a".repeat(64)}` }); + for (const type of ["run.prepare", "session.open", "turn.start", "runner.suspend"]) Object.assign(core.queueCommand(type), { status: "completed", result: { status: "completed" } }); + core.store.state.committedEvents.push({ sourceSeq: 1, sourceEventId: "fixture-terminal", eventType: "run.terminal", priority: 0, envelope: { ...core.store.state.identity }, deliveryCount: 1, logicalEffectCount: 1 }); + core.store.state.ackedSourceSeq = 1; + core.setCheckpointPersistence(nativeRunnerCheckpointWriter(f.db, { companyId, issueId: f.issueId, runId: f.runId, authorityRunId: f.runId, bootId, generation: 1, leaseOwner })); + await core.flushCheckpoint(); await core.stop(); + const [result] = await f.db.insert(nativeRunResults).values({ companyId, issueId: f.issueId, runId: f.runId, completionContractId: sourceRun!.completionContractId!, + serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: yielded, canonicalSha256: "a".repeat(64) }).returning(); + const fingerprint = nativeToolContractFingerprintForTarget("remote", "codex"); + const descriptor = nativeControllerMigrationDescriptor(execution, fingerprint); + const sourceProfile = { ...f.preparedProfile, nativeExecutionInput: execution, sessionCheckpoint: snapshot, nativeToolContractFingerprint: fingerprint, + nativeControllerRecovery: createNativeControllerRecovery(snapshot, { configDigest: descriptor.configDigest, processConfigDigest: null }, + { runId: f.runId, runnerInstanceId: f.native.runnerInstanceId, normalizedSessionId: f.native.normalizedSessionId }) }; + await f.db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date(), runnerProfileJson: sourceProfile }).where(eq(heartbeatRuns.id, f.runId)); + await f.db.update(nativeRunFinalizations).set({ phase: "committed", resultId: result!.id, leaseOwner: null, leaseExpiresAt: null }).where(eq(nativeRunFinalizations.runId, f.runId)); + + const runId = randomUUID(); + const [run] = await f.db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: f.issueId, issueId: f.issueId, status: "running", + runtimeMode: "native", nativeSessionId: f.native.normalizedSessionId, runnerInstanceId: f.native.runnerInstanceId, + completionContractId: sourceRun!.completionContractId, completionContractSha256: sourceRun!.completionContractSha256 }).returning(); + const native = await prepareNativeHeartbeatRun({ db: f.db, run: run!, issue: { id: f.issueId, title: "Approved work", description: null }, environmentLeaseId: randomUUID() }); + const current = { ...execution, binding: { ...execution.binding, runId } }; + const rebound = rebindNativeSessionCheckpoint({ previousRun: { id: f.runId, companyId, agentId, nativeSessionId: f.native.normalizedSessionId, runnerProfileJson: sourceProfile }, + currentExecution: current, executionTargetKind: "remote" }); + expect(rebound?.providerRecoveryPolicy).toBe("allow_replacement_after_governed_wait"); + const [prepared] = await f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + await f.db.update(heartbeatRuns).set({ runnerProfileJson: { ...prepared!.runnerProfileJson, nativeExecutionInput: current, sessionCheckpoint: rebound } }).where(eq(heartbeatRuns.id, runId)); + await f.db.insert(nativeRunFinalizations).values({ runId, companyId, issueId: f.issueId, phase: "observed" }); + await f.db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId)); + + const recover = vi.fn(async () => ({ recovered: false as const, failureClass: "provider_state_incompatible" as const, resumeStateUnavailable: true as const, + reason: "Error: NativeRunnerResumeStateUnavailableError: PRP provider resume state is unavailable" })); + const replacementObserved = new Error("fixture fresh provider session opened"); + const fresh = vi.fn(async () => { throw replacementObserved; }); + const backend: NativeSessionBackend = { + descriptor: async () => ({ kind: "runner", name: "controlled provider boundary", version: "fixture", + capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true }, + runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native" } }), + recoverSession: recover, openSession: fresh, openReplacementSession: fresh, + }; + const execute = () => executePaperclipNativeSession({ db: f.db, execution: current, runnerInstanceId: native.runnerInstanceId, useRunnerd: true, backend, + runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: runtimeRoot, environmentId: randomUUID(), leaseId: randomUUID(), + providerKey: "fixture", reusableLeaseConfigured: true, runner: { execute: async () => { throw new Error("unexpected remote process execution"); } }, + sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "new-fixture-sandbox" } } as never }).catch((error: unknown) => error); + return { f, companyId, agentId, runId, recover, fresh, replacementObserved, execute }; + } + + const proofFor = (h: Awaited>) => new PaperclipControlPlanePort(h.f.db, { + companyId: h.companyId, issueId: h.f.issueId, runId: h.runId, agentId: h.agentId, sessionId: "proof-session", + completionContractId: "proof-contract", completionContractSha256: "proof-contract", sourceInstanceId: "proof-runner", + controlPlaneSourceInstanceId: "proof-control", + }).provesNoRunProviderWork({ signal: new AbortController().signal }); + + it("opens a fresh provider session for the follow-up without a Retry", async () => { + const h = await followUpRun(); + expect(await proofFor(h)).toBe(true); + const failure = await h.execute(); + expect(h.recover, String(failure)).toHaveBeenCalledOnce(); + // The fixture stops at the fresh session's boundary; on master the run + // ended with native_provider_state_incompatible before reaching it. + expect(failure).toBe(h.replacementObserved); + expect(h.fresh).toHaveBeenCalledOnce(); + }, 60_000); + + it.each([ + ["a provider event", "event"], + ["a tool invocation", "tool"], + ["an accepted result", "result"], + // A runner event of a non-provider type still carries a source identity: + // a runner wrote it for this run, so the run is not untouched. + ["a sourced runner event of a non-provider type", "sourced_event"], + // The foreign key normally forbids this. A row restored without its result + // must still deny the proof rather than look like an untouched run. + ["a finalized result whose result row is missing", "dangling_result"], + ] as const)("keeps the named refusal when the follow-up run already recorded %s", async (_label, evidence) => { + const h = await followUpRun(); + if (evidence === "event") await h.f.db.insert(heartbeatRunEvents).values({ companyId: h.companyId, runId: h.runId, agentId: h.agentId, + seq: 1_000, eventType: "turn.started", stream: "system", level: "info", message: "turn started", payload: {} }); + if (evidence === "tool") await h.f.db.insert(toolInvocations).values({ companyId: h.companyId, runId: h.runId, agentId: h.agentId, + toolName: "paperclip_get_issue", riskLevel: "read", status: "succeeded" } as never); + if (evidence === "result") { + const [run] = await h.f.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, h.runId)); + await h.f.db.insert(nativeRunResults).values({ companyId: h.companyId, issueId: h.f.issueId, runId: h.runId, completionContractId: run!.completionContractId!, + serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: "b".repeat(64) }); + } + if (evidence === "sourced_event") await h.f.db.insert(heartbeatRunEvents).values({ companyId: h.companyId, runId: h.runId, agentId: h.agentId, + seq: 1_000, eventType: "native.diagnostic", stream: "system", level: "info", message: "runner diagnostic", payload: {}, + sourceInstanceId: "foreign-runner-instance", sourceEventId: "foreign-runner-instance:event:1", sourceSeq: 1 }); + if (evidence === "dangling_result") await h.f.db.transaction(async (tx) => { + await tx.execute(sql`set local session_replication_role = 'replica'`); + await tx.update(nativeRunFinalizations).set({ resultId: randomUUID() }).where(eq(nativeRunFinalizations.runId, h.runId)); + }); + if (evidence === "sourced_event" || evidence === "dangling_result") { + // The fixture is the only evidence: no tool invocation or result row. + expect(await h.f.db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, h.runId))).toEqual([]); + expect(await h.f.db.select().from(toolInvocations).where(eq(toolInvocations.runId, h.runId))).toEqual([]); + } + // The durable proof itself refuses each kind of evidence. + expect(await proofFor(h)).toBe(false); + const failure = await h.execute(); + expect(h.fresh).not.toHaveBeenCalled(); + if (evidence === "dangling_result") { + // Admission stops even earlier on a finalized result: still no fresh session. + expect(String(failure)).toContain("native_result_pending_finalization"); + return; + } + expect(h.recover, String(failure)).toHaveBeenCalledOnce(); + expect((failure as { code?: string }).code).toBe("native_provider_state_incompatible"); + }, 60_000); +}); diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index 39af5d919de..5b21bc744db 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -10,6 +10,7 @@ import { nativeRunFinalizations, nativeRunResults, nativeRunnerCheckpoints, + toolInvocations, } from "@paperclipai/db"; import type { CompleteControlPlaneRunInput, @@ -36,6 +37,7 @@ import { eligibleNativeTaskSessionPublication, publishNativeTaskSessionInTransac type NativeTaskSessionPublicationContext, type NativeTaskSessionPublicationIssue } from "./native-task-session-publication.js"; import { lockNativeRunnerCheckpointOwner, nativeRunnerCheckpointOwnerLock, validateNativeRunnerCheckpointOwner, inspectNativeRunnerUpgradeHeader, type NativeRunnerCheckpointOwner } from "./native-runner-checkpoint.js"; import { crashPoint } from "@paperclipai/shared/crash-points"; +import { NATIVE_PROVIDER_DURABLE_EVENT_TYPES } from "./native-retired-backup-source.js"; export interface PaperclipControlPlaneBinding { companyId: string; @@ -512,6 +514,34 @@ export class PaperclipControlPlanePort implements ControlPlanePort { return { events, highestContiguousSourceSeq: cursor }; } + /** + * Proves from durable rows, across every runner instance, that this run has + * no provider event, source-sequenced runner event, tool invocation, or + * accepted result. Any such row, or a read failure, denies the proof. + */ + async provesNoRunProviderWork(options: { signal: AbortSignal }): Promise { + options.signal.throwIfAborted(); + const { companyId, runId } = this.#binding; + const providerEvents = sql.join([...NATIVE_PROVIDER_DURABLE_EVENT_TYPES].map(value => sql`${value}`), sql`, `); + const result = await this.#db.execute(sql`select + exists (select 1 from ${heartbeatRunEvents} event where event.company_id = ${companyId} and event.run_id = ${runId} + and (event.source_event_id is not null or event.source_instance_id is not null + or event.event_type in (${providerEvents}) + or event.event_type ~ '^(turn|item|tool|semantic_tool|run[.]result)[.]')) as "providerEvent", + exists (select 1 from ${toolInvocations} invocation where invocation.company_id = ${companyId} + and invocation.run_id = ${runId}) as "toolInvocation", + exists (select 1 from ${nativeRunResults} result where result.company_id = ${companyId} + and result.run_id = ${runId}) as "acceptedResult", + exists (select 1 from ${nativeRunFinalizations} coordinator where coordinator.run_id = ${runId} + and coordinator.result_id is not null) as "finalizedResult"`); + options.signal.throwIfAborted(); + const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as Array>; + const row = rows[0]; + if (!row) return false; + return row.providerEvent === false && row.toolInvocation === false + && row.acceptedResult === false && row.finalizedResult === false; + } + async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput, options?: { signal: AbortSignal }): Promise { this.#assertActive(options); if (this.#deferredEventObservers.size > 0 || this.#deferredObserverFailure) await this.#settleDeferredObservers(options);