From df8ae3480ba5311c8e2f9b50b16b3ee0d0d35b7d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 15:33:57 -0400 Subject: [PATCH 01/29] Preserve queued drafts until server acceptance --- .../embedded-chat/EmbeddedThreadChat.test.tsx | 26 +++++++++++++- .../embedded-chat/EmbeddedThreadChat.tsx | 10 +++++- .../ThreadDetailPromptArea.test.tsx | 34 +++++++++++++++++++ .../thread-detail/ThreadDetailPromptArea.tsx | 5 ++- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index e3da67a42a0..5ad39389f44 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createDeferredPromise } from "@bb/test-helpers"; import { useEffect, useLayoutEffect, type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FollowUpComposerProps } from "@/components/promptbox/FollowUpPromptBox"; @@ -517,6 +518,29 @@ describe("EmbeddedThreadChat", () => { ).toBe(""); }); + it("preserves a pending queued draft through unmount and network failure", async () => { + const submission = createDeferredPromise(); + mocks.createQueuedMessageMutateAsync.mockReturnValueOnce(submission.promise); + mocks.threadRuntimeDisplayStatus = "active"; + const view = renderEmbeddedChat(); + fireEvent.change(screen.getByTestId("embedded-chat-composer"), { + target: { value: "Do not lose this message" }, + }); + fireEvent.click(screen.getByText("Send")); + + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Do not lose this message"); + view.unmount(); + await act(async () => { + submission.reject(new TypeError("Failed to fetch")); + }); + renderEmbeddedChat(); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Do not lose this message"); + }); + it("sends directly when the thread runtime is idle", async () => { mocks.threadRuntimeDisplayStatus = "idle"; renderEmbeddedChat(); diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 63a54c76816..f7c94d26c53 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -494,10 +494,18 @@ function EmbeddedThreadChatWithComposer({ if (submittedInput.length === 0 || isTurnSubmitting) { return; } - promptDraft.clearIfCurrentMatches(submittedDraft); + const isQueuingMessage = shouldQueueFollowUpMessage(displayStatus); + if (!isQueuingMessage) { + promptDraft.clearIfCurrentMatches(submittedDraft); + } setBottomAttachmentError(null); setIsTurnSubmitting(true); void defaultSendOrQueueInput(submittedInput) + .then(() => { + if (isQueuingMessage) { + promptDraft.clearIfCurrentMatches(submittedDraft); + } + }) .catch((error) => { if (!isMountedRef.current) { return; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index c11b8fd218f..c13a1b952e6 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -957,6 +957,40 @@ describe("environment follow-up summary", () => { }); describe("ThreadDetailPromptArea", () => { + it.each(["accepted", "failed"])( + "retains a queued draft until the request is %s", + async (outcome) => { + const submission = createDeferredPromise(); + mocks.createQueuedMessageMutateAsync.mockReturnValueOnce( + submission.promise, + ); + mocks.promptDraft.text = "Keep this message safe"; + const submittedDraft = mocks.promptDraft.getCurrent(); + renderPromptArea({ thread: makeThread({ status: "active" }) }); + + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + + expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledTimes(1); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + + await act(async () => { + if (outcome === "accepted") { + submission.resolve(makeQueuedMessage()); + } else { + submission.reject(new TypeError("Failed to fetch")); + } + }); + + if (outcome === "accepted") { + expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledWith( + submittedDraft, + ); + } else { + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + } + }, + ); + it("preserves plugin submission data through a follow-up composer", async () => { mocks.defaultExecutionOptions = { model: "gpt-5", diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index e348c92a945..3416528f01f 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1058,7 +1058,9 @@ export function ThreadDetailPromptArea({ return; } - promptDraft.clearIfCurrentMatches(submittedDraft); + if (!isQueuingMessage) { + promptDraft.clearIfCurrentMatches(submittedDraft); + } setBottomAttachmentError(null); try { @@ -1070,6 +1072,7 @@ export function ThreadDetailPromptArea({ }); if (request) { await createQueuedMessage.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); } } else { const request = buildAutoFollowUpRequest({ From b0eada15a679b98efb646939fe9522641cdc8562 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 15:34:44 -0400 Subject: [PATCH 02/29] Set active runtime in queued draft regression fixture --- .../thread-detail/ThreadDetailPromptArea.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index c13a1b952e6..29dc8556996 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -966,7 +966,15 @@ describe("ThreadDetailPromptArea", () => { ); mocks.promptDraft.text = "Keep this message safe"; const submittedDraft = mocks.promptDraft.getCurrent(); - renderPromptArea({ thread: makeThread({ status: "active" }) }); + renderPromptArea({ + thread: makeThread({ + status: "active", + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + }), + }); fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); From 646a88c758689fedf0355382074a1a5acef91162 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 15:40:02 -0400 Subject: [PATCH 03/29] Await queue acceptance in embedded composer test --- .../thread/embedded-chat/EmbeddedThreadChat.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index 5ad39389f44..4a61e625cf2 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -513,9 +513,11 @@ describe("EmbeddedThreadChat", () => { }), ); expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe(""); + await vi.waitFor(() => { + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe(""); + }); }); it("preserves a pending queued draft through unmount and network failure", async () => { From 8f5e0230dc45c22ecdee0f164ecdb34faca16557 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 17:11:13 -0400 Subject: [PATCH 04/29] Preserve queued submission locks across composer mounts --- .../embedded-chat/EmbeddedThreadChat.test.tsx | 31 +++++++++- .../embedded-chat/EmbeddedThreadChat.tsx | 12 +++- .../thread-runtime-mutations.test.tsx | 57 +++++++++++++++++++ .../mutations/thread-runtime-mutations.ts | 28 ++++++++- .../ThreadDetailPromptArea.test.tsx | 14 ++++- .../thread-detail/ThreadDetailPromptArea.tsx | 5 +- 6 files changed, 136 insertions(+), 11 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index 4a61e625cf2..833d1040866 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { createDeferredPromise } from "@bb/test-helpers"; import { useEffect, useLayoutEffect, type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,6 +16,7 @@ import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; import { EmbeddedThreadChat } from "./EmbeddedThreadChat"; const mocks = vi.hoisted(() => ({ + createQueuedMessageIsPending: false, createQueuedMessageMutateAsync: vi.fn(), markThreadReadMutate: vi.fn(), onOpenLink: vi.fn(), @@ -300,7 +307,7 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ useCreateThreadQueuedMessage: () => ({ mutateAsync: mocks.createQueuedMessageMutateAsync, mutate: vi.fn(), - isPending: false, + isPending: mocks.createQueuedMessageIsPending, }), useSendThreadMessage: () => ({ mutateAsync: mocks.sendThreadMessageMutateAsync, @@ -395,6 +402,7 @@ function renderEmbeddedChat( describe("EmbeddedThreadChat", () => { beforeEach(() => { + mocks.createQueuedMessageIsPending = false; window.localStorage.clear(); mocks.createQueuedMessageMutateAsync.mockReset().mockResolvedValue({}); mocks.sendThreadMessageMutateAsync.mockReset().mockResolvedValue({}); @@ -522,7 +530,9 @@ describe("EmbeddedThreadChat", () => { it("preserves a pending queued draft through unmount and network failure", async () => { const submission = createDeferredPromise(); - mocks.createQueuedMessageMutateAsync.mockReturnValueOnce(submission.promise); + mocks.createQueuedMessageMutateAsync.mockReturnValueOnce( + submission.promise, + ); mocks.threadRuntimeDisplayStatus = "active"; const view = renderEmbeddedChat(); fireEvent.change(screen.getByTestId("embedded-chat-composer"), { @@ -543,6 +553,21 @@ describe("EmbeddedThreadChat", () => { ).toBe("Do not lose this message"); }); + it("does not resubmit a draft while another composer owns its pending request", () => { + mocks.createQueuedMessageIsPending = true; + mocks.threadRuntimeDisplayStatus = "active"; + renderEmbeddedChat(); + fireEvent.change(screen.getByTestId("embedded-chat-composer"), { + target: { value: "Already submitting" }, + }); + fireEvent.click(screen.getByText("Send")); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Already submitting"); + }); + it("sends directly when the thread runtime is idle", async () => { mocks.threadRuntimeDisplayStatus = "idle"; renderEmbeddedChat(); diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index f7c94d26c53..28b9e2d8037 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -238,7 +238,7 @@ function EmbeddedThreadChatWithComposer({ const markThreadRead = useMarkThreadRead(); const stopThread = useStopThread(); const sendThreadMessage = useSendThreadMessage(); - const createQueuedMessage = useCreateThreadQueuedMessage(); + const createQueuedMessage = useCreateThreadQueuedMessage(threadId); const threadQuery = useThread(threadId); const pendingInteractionsQuery = useThreadPendingInteractions(threadId); const activePendingInteraction = getLatestPendingInteraction( @@ -491,7 +491,11 @@ function EmbeddedThreadChatWithComposer({ const handleSubmit = useCallback(() => { const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; - if (submittedInput.length === 0 || isTurnSubmitting) { + if ( + submittedInput.length === 0 || + isTurnSubmitting || + createQueuedMessage.isPending + ) { return; } const isQueuingMessage = shouldQueueFollowUpMessage(displayStatus); @@ -525,6 +529,7 @@ function EmbeddedThreadChatWithComposer({ } }); }, [ + createQueuedMessage.isPending, currentPromptDraft, currentPromptDraftInput, defaultSendOrQueueInput, @@ -806,7 +811,7 @@ function EmbeddedThreadChatWithComposer({ entries: [], onSelectEntry: promptDraft.setDraft, } satisfies HistoryConfig, - isFollowUpSubmitting: isTurnSubmitting, + isFollowUpSubmitting: isTurnSubmitting || createQueuedMessage.isPending, message: currentPromptDraft.text, mentionRanges: currentPromptDraft.mentions, onChangeMessage: promptDraft.setTextAndMentions, @@ -822,6 +827,7 @@ function EmbeddedThreadChatWithComposer({ [ canSubmitModifierShortcut, composerPlaceholder, + createQueuedMessage.isPending, currentPromptDraft, displayStatus, handleModifierSubmit, diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index 78052714adb..f14d6fa4f36 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -477,6 +477,63 @@ describe("thread runtime mutations", () => { ); }); + it.each(["accepted", "failed"])( + "shares a pending queue submission across remounts until it is %s", + async (outcome) => { + const { wrapper } = createQueryClientTestHarness(); + const submission = createDeferredPromise(); + vi.mocked(sdk.threads.queuedMessages.create).mockReturnValueOnce( + submission.promise, + ); + const first = renderHook(() => useCreateThreadQueuedMessage("thread-1"), { + wrapper, + }); + const request = { + id: "thread-1", + input: [ + { type: "text" as const, text: "Keep this draft", mentions: [] }, + ], + }; + act(() => { + void first.result.current.mutateAsync(request).catch(() => undefined); + }); + await waitFor(() => + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), + ); + first.unmount(); + + const reopened = renderHook( + () => useCreateThreadQueuedMessage("thread-1"), + { + wrapper, + }, + ); + const unrelated = renderHook( + () => useCreateThreadQueuedMessage("thread-2"), + { + wrapper, + }, + ); + expect(reopened.result.current.isPending).toBe(true); + expect(unrelated.result.current.isPending).toBe(false); + + await act(async () => { + if (outcome === "accepted") { + submission.resolve(makeQueuedMessage()); + } else { + submission.reject(new TypeError("Failed to fetch")); + } + }); + await waitFor(() => + expect(reopened.result.current.isPending).toBe(false), + ); + await act(async () => { + await reopened.result.current.mutateAsync(request); + }); + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(2); + }, + ); + it("keeps an optimistically deleted queued message removed when the server says it is already gone", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); queryClient.setQueryData(threadQueuedMessagesQueryKey("thread-1"), [ diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts index 5494d1e7ce0..fcd338423b2 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts @@ -1,5 +1,9 @@ import { notifyComposerSubmitted } from "@/lib/composer-submissions"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useIsMutating, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import type { ThreadQueuedMessage } from "@bb/domain"; import type { CreateQueuedMessageRequest, @@ -245,10 +249,26 @@ export function useEditThreadMessage() { }); } -export function useCreateThreadQueuedMessage() { +const createThreadQueuedMessageMutationKey = ["create-thread-queued-message"]; + +export function useCreateThreadQueuedMessage(threadId?: string) { const queryClient = useQueryClient(); + const pendingCount = useIsMutating({ + mutationKey: createThreadQueuedMessageMutationKey, + predicate: ({ state }) => { + const variables = state.variables; + return ( + threadId !== undefined && + typeof variables === "object" && + variables !== null && + "id" in variables && + variables.id === threadId + ); + }, + }); - return useMutation({ + const mutation = useMutation({ + mutationKey: createThreadQueuedMessageMutationKey, meta: { errorMessage: "Failed to queue message.", lifecycleOperation: "queue_message", @@ -296,6 +316,8 @@ export function useCreateThreadQueuedMessage() { }); }, }); + + return { ...mutation, isPending: mutation.isPending || pendingCount > 0 }; } export function useUpdateThreadQueuedMessage() { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 29dc8556996..ac5aba58ae1 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -50,6 +50,7 @@ import { import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; const mocks = vi.hoisted(() => ({ + createQueuedMessageIsPending: false, cancelThreadPlanMutate: vi.fn(), clearThreadGoalMutate: vi.fn(), createQueuedMessageMutateAsync: vi.fn(), @@ -662,7 +663,7 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ mutateAsync: mocks.createThreadMutateAsync, }), useCreateThreadQueuedMessage: () => ({ - isPending: false, + isPending: mocks.createQueuedMessageIsPending, mutateAsync: mocks.createQueuedMessageMutateAsync, }), useDeleteThreadQueuedMessage: () => ({ @@ -896,6 +897,7 @@ function renderPromptArea(options: RenderPromptAreaOptions = {}) { } beforeEach(() => { + mocks.createQueuedMessageIsPending = false; testQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -957,6 +959,16 @@ describe("environment follow-up summary", () => { }); describe("ThreadDetailPromptArea", () => { + it("does not resubmit a draft while another composer owns its pending request", () => { + mocks.createQueuedMessageIsPending = true; + mocks.promptDraft.text = "Already submitting"; + renderPromptArea(); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + }); + it.each(["accepted", "failed"])( "retains a queued draft until the request is %s", async (outcome) => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 3416528f01f..be27eac28cd 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -464,7 +464,7 @@ export function ThreadDetailPromptArea({ enabled: promptHistoryEnabled, }, ); - const createQueuedMessage = useCreateThreadQueuedMessage(); + const createQueuedMessage = useCreateThreadQueuedMessage(thread.id); const stopThread = useStopThread(); const cancelThreadPlan = useCancelThreadPlan(); const clearThreadGoal = useClearThreadGoal(); @@ -1042,6 +1042,9 @@ export function ThreadDetailPromptArea({ ); const handleSend = useCallback(async () => { + if (createQueuedMessage.isPending) { + return; + } const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; if (isHandoffSelection) { From f1af6c781b1cc20de413bcef8104791e76ca4f6b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 22:40:03 -0400 Subject: [PATCH 05/29] Lock message drafts until submission settles --- .../promptbox/FollowUpPromptBox.tsx | 8 +- .../promptbox/PromptBoxInternal.test.tsx | 106 ++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 22 ++-- .../embedded-chat/EmbeddedThreadChat.test.tsx | 51 +++++---- .../embedded-chat/EmbeddedThreadChat.tsx | 24 ++-- .../thread-runtime-mutations.test.tsx | 67 ++++++----- .../mutations/thread-runtime-mutations.ts | 23 +++- .../ThreadDetailPromptArea.test.tsx | 33 ++++++ .../thread-detail/ThreadDetailPromptArea.tsx | 45 +++----- .../views/thread-detail/ThreadDetailView.tsx | 2 +- 10 files changed, 274 insertions(+), 107 deletions(-) diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 99494a214af..8c5eba58ab8 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -606,7 +606,9 @@ function FollowUpPromptBoxWithComposer({ const modifierSubmitHint = (action: "queue" | "steer"): string => onModifierSubmit ? `, ${modifierSubmitShortcutLabel()} to ${action}` : ""; const executionControlsDisabled = - (executionReadOnly ?? false) || hasPendingInteraction; + (executionReadOnly ?? false) || + hasPendingInteraction || + composer.isFollowUpSubmitting; const footerStart = useMemo( () => ( @@ -621,7 +623,9 @@ function FollowUpPromptBoxWithComposer({ activePromptMode, }); const permissionReadOnlyResolved = - (permissionReadOnly ?? false) || hasPendingInteraction; + (permissionReadOnly ?? false) || + hasPendingInteraction || + composer.isFollowUpSubmitting; const permissionPickerDisabled = permissionReadOnlyResolved || permissionPickerDisabledByPlanMode; const permissionControl = useMemo( diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 48c3594c9be..5417a62aa8f 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1197,6 +1197,112 @@ describe("PromptBoxInternal controlled value sync", () => { }); describe("PromptBoxInternal submit shortcuts", () => { + it("keeps text and attachments unchanged while submitting and unlocks when settled", async () => { + const onChange = vi.fn(); + const onAttachFiles = vi.fn(); + const onRemove = vi.fn(); + const promptBoxRef = createRef(); + const props = createPromptBoxProps({ + value: "Keep this message", + onChange, + promptBoxRef, + attachments: { + items: [ + { + type: "localFile", + name: "notes.txt", + path: "notes.txt", + sizeBytes: 1, + }, + ], + onAttachFiles, + onRemove, + }, + }); + const view = render( + , + ); + const editor = getPromptEditorElement(); + await waitFor(() => + expect(editor.getAttribute("contenteditable")).toBe("false"), + ); + onChange.mockClear(); + const file = new File(["new attachment"], "new.txt", { + type: "text/plain", + }); + const attachmentInput = view.container.querySelector('input[type="file"]'); + if (!(attachmentInput instanceof HTMLInputElement)) + throw new Error("Attachment input was not rendered"); + pasteClipboard({ files: [file], plainText: "overwrite" }); + fireEvent.change(attachmentInput, { target: { files: [file] } }); + fireEvent.drop(editor, { dataTransfer: { files: [file] } }); + expect( + screen.queryByRole("button", { name: "Remove notes.txt" }), + ).toBeNull(); + expect(editor.textContent).toBe("Keep this message"); + expect(onChange).not.toHaveBeenCalled(); + expect(onAttachFiles).not.toHaveBeenCalled(); + expect(onRemove).not.toHaveBeenCalled(); + + view.rerender( + , + ); + await waitFor(() => + expect(editor.getAttribute("contenteditable")).toBe("true"), + ); + await focusPromptEnd(promptBoxRef); + pastePlainText(" after failure"); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith( + "Keep this message after failure", + [], + ), + ); + fireEvent.change(attachmentInput, { target: { files: [file] } }); + expect(onAttachFiles).toHaveBeenCalledWith([file]); + fireEvent.click(screen.getByRole("button", { name: "Remove notes.txt" })); + expect(onRemove).toHaveBeenCalledWith("notes.txt"); + }); + + it("prevents history recall while submitting and restores it when settled", async () => { + const onSelectEntry = vi.fn(); + const promptBoxRef = createRef(); + const previousDraft = { + ...emptyPromptDraftState(), + text: "Previous message", + }; + const props = createPromptBoxProps({ + promptBoxRef, + history: { + currentDraft: emptyPromptDraftState(), + entries: [previousDraft], + onSelectEntry, + resetKey: "submission-history", + }, + }); + const view = render( + , + ); + const editor = getPromptEditorElement(); + await waitFor(() => + expect(editor.getAttribute("contenteditable")).toBe("false"), + ); + await focusPromptEnd(promptBoxRef); + fireEvent.keyDown(editor, { key: "ArrowUp" }); + expect(onSelectEntry).not.toHaveBeenCalled(); + expect(editor.textContent).toBe(""); + + view.rerender( + , + ); + await waitFor(() => + expect(editor.getAttribute("contenteditable")).toBe("true"), + ); + await focusPromptEnd(promptBoxRef); + fireEvent.keyDown(editor, { key: "ArrowUp" }); + expect(onSelectEntry).toHaveBeenCalledWith(previousDraft); + }); + it("exposes the disabled submit reason as its label and hover tooltip", async () => { const reason = "Loading models from the selected machine..."; render( diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 864de69c5b2..80c29bfd661 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1236,10 +1236,12 @@ export function PromptBoxInternal({ items: attachments = [], isAttaching = false, error: attachmentError = null, - onAttachFiles, - onRemove: onRemoveAttachment, + onAttachFiles: attachFiles, + onRemove: removeAttachment, projectId: attachmentProjectId, } = attachmentConfig; + const onAttachFiles = isSubmitting ? undefined : attachFiles; + const onRemoveAttachment = isSubmitting ? undefined : removeAttachment; const isPointerCoarse = usePointerCoarse(); const isIPadOSWebKitDevice = useMemo(isIPadOSWebKit, []); const editorEnterKeyHint = isPointerCoarse ? "enter" : "send"; @@ -1438,9 +1440,10 @@ export function PromptBoxInternal({ ? (compact.placeholder ?? placeholder) : placeholder; const pluginComposerHost = usePluginComposerHost(); - const composerInputLocked = useComposerInputLock( + const pluginInputLocked = useComposerInputLock( pluginComposerHost?.textEffectKey ?? null, ); + const composerInputLocked = pluginInputLocked || isSubmitting; const composerLayout = showCompactLayout ? "compact" : "expanded"; const localComposerView = usePluginComposerViewModel({ scope: pluginComposerHost?.scope ?? DEFAULT_COMPOSER_SCOPE, @@ -1565,7 +1568,10 @@ export function PromptBoxInternal({ } return [ ...mentionTriggers, - ...commandTriggerChars.map((char) => ({ char, kind: "command" as const })), + ...commandTriggerChars.map((char) => ({ + char, + kind: "command" as const, + })), ]; }, [commandTriggerChars, mentionTriggerChars]); @@ -1871,7 +1877,7 @@ export function PromptBoxInternal({ [richTextEditing], ); - useEffect(() => { + useLayoutEffect(() => { if (!editor || editor.isDestroyed) return; const editable = !composerInputLocked && !showVoiceActionGroup; if (editor.isEditable !== editable) editor.setEditable(editable); @@ -2737,7 +2743,7 @@ export function PromptBoxInternal({ const applyHistoryDraft = useCallback( (draft: PromptDraftState) => { - if (!history) { + if (!history || isSubmitting) { return; } @@ -2753,7 +2759,7 @@ export function PromptBoxInternal({ scheduleRevealEditorSelection(); }); }, - [history, scheduleRevealEditorSelection, syncTriggerState], + [history, isSubmitting, scheduleRevealEditorSelection, syncTriggerState], ); const collapsePromptBox = useCallback(() => { @@ -2801,6 +2807,7 @@ export function PromptBoxInternal({ ) { return false; } + if (isSubmitting) return false; if (dispatchAppCommandKey(event)) { return true; } @@ -3028,6 +3035,7 @@ export function PromptBoxInternal({ dismissActiveTrigger, history, isPointerCoarse, + isSubmitting, loadMoreCommands, onEscape, onModifierSubmit, diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index 833d1040866..3ca4e714ee2 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -528,30 +528,35 @@ describe("EmbeddedThreadChat", () => { }); }); - it("preserves a pending queued draft through unmount and network failure", async () => { - const submission = createDeferredPromise(); - mocks.createQueuedMessageMutateAsync.mockReturnValueOnce( - submission.promise, - ); - mocks.threadRuntimeDisplayStatus = "active"; - const view = renderEmbeddedChat(); - fireEvent.change(screen.getByTestId("embedded-chat-composer"), { - target: { value: "Do not lose this message" }, - }); - fireEvent.click(screen.getByText("Send")); + it.each(["active", "idle"] as const)( + "preserves a pending draft through unmount and network failure when %s", + async (status) => { + const submission = createDeferredPromise(); + const submit = + status === "active" + ? mocks.createQueuedMessageMutateAsync + : mocks.sendThreadMessageMutateAsync; + submit.mockReturnValueOnce(submission.promise); + mocks.threadRuntimeDisplayStatus = status; + const view = renderEmbeddedChat(); + fireEvent.change(screen.getByTestId("embedded-chat-composer"), { + target: { value: "Do not lose this message" }, + }); + fireEvent.click(screen.getByText("Send")); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe("Do not lose this message"); - view.unmount(); - await act(async () => { - submission.reject(new TypeError("Failed to fetch")); - }); - renderEmbeddedChat(); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe("Do not lose this message"); - }); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Do not lose this message"); + view.unmount(); + await act(async () => { + submission.reject(new TypeError("Failed to fetch")); + }); + renderEmbeddedChat(); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Do not lose this message"); + }, + ); it("does not resubmit a draft while another composer owns its pending request", () => { mocks.createQueuedMessageIsPending = true; diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 28b9e2d8037..349566df7ca 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -237,7 +237,7 @@ function EmbeddedThreadChatWithComposer({ const surfaceKey = threadId; const markThreadRead = useMarkThreadRead(); const stopThread = useStopThread(); - const sendThreadMessage = useSendThreadMessage(); + const sendThreadMessage = useSendThreadMessage(threadId); const createQueuedMessage = useCreateThreadQueuedMessage(threadId); const threadQuery = useThread(threadId); const pendingInteractionsQuery = useThreadPendingInteractions(threadId); @@ -494,27 +494,21 @@ function EmbeddedThreadChatWithComposer({ if ( submittedInput.length === 0 || isTurnSubmitting || + sendThreadMessage.isPending || createQueuedMessage.isPending ) { return; } - const isQueuingMessage = shouldQueueFollowUpMessage(displayStatus); - if (!isQueuingMessage) { - promptDraft.clearIfCurrentMatches(submittedDraft); - } setBottomAttachmentError(null); setIsTurnSubmitting(true); void defaultSendOrQueueInput(submittedInput) .then(() => { - if (isQueuingMessage) { - promptDraft.clearIfCurrentMatches(submittedDraft); - } + promptDraft.clearIfCurrentMatches(submittedDraft); }) .catch((error) => { if (!isMountedRef.current) { return; } - promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error, fallbackMessage: "Failed to send message", @@ -535,6 +529,7 @@ function EmbeddedThreadChatWithComposer({ defaultSendOrQueueInput, displayStatus, isTurnSubmitting, + sendThreadMessage.isPending, promptDraft, setBottomAttachmentError, ]); @@ -554,7 +549,7 @@ function EmbeddedThreadChatWithComposer({ const hasPromptDraftInput = currentPromptDraftInput.length > 0; const canSubmitModifierShortcut = canSubmitFollowUpShortcut({ hasPromptDraftInput, - isFollowUpSubmitting: isTurnSubmitting, + isFollowUpSubmitting: isTurnSubmitting || sendThreadMessage.isPending, isQueueMutationPending, queuedMessageCount: queuedMessages.length, runtimeDisplayStatus: displayStatus, @@ -579,7 +574,6 @@ function EmbeddedThreadChatWithComposer({ return; } - promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); setIsTurnSubmitting(true); void sendThreadMessage @@ -590,13 +584,13 @@ function EmbeddedThreadChatWithComposer({ ...executionRequestFields, }) .then((result) => { + promptDraft.clearIfCurrentMatches(submittedDraft); reportQueuedSendDelivery(result.delivery); }) .catch((error) => { if (!isMountedRef.current) { return; } - promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error, fallbackMessage: "Failed to send message", @@ -811,7 +805,10 @@ function EmbeddedThreadChatWithComposer({ entries: [], onSelectEntry: promptDraft.setDraft, } satisfies HistoryConfig, - isFollowUpSubmitting: isTurnSubmitting || createQueuedMessage.isPending, + isFollowUpSubmitting: + isTurnSubmitting || + sendThreadMessage.isPending || + createQueuedMessage.isPending, message: currentPromptDraft.text, mentionRanges: currentPromptDraft.mentions, onChangeMessage: promptDraft.setTextAndMentions, @@ -833,6 +830,7 @@ function EmbeddedThreadChatWithComposer({ handleModifierSubmit, handleSubmit, isTurnSubmitting, + sendThreadMessage.isPending, promptDraft.setDraft, promptDraft.setTextAndMentions, steerActiveThreadOnEnter, diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index f14d6fa4f36..fce3aa34ae4 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -477,19 +477,37 @@ describe("thread runtime mutations", () => { ); }); - it.each(["accepted", "failed"])( - "shares a pending queue submission across remounts until it is %s", - async (outcome) => { + it.each([ + ["send", "accepted"], + ["send", "failed"], + ["queue", "accepted"], + ["queue", "failed"], + ] as const)( + "shares a pending %s submission across remounts until it is %s", + async (kind, outcome) => { const { wrapper } = createQueryClientTestHarness(); - const submission = createDeferredPromise(); - vi.mocked(sdk.threads.queuedMessages.create).mockReturnValueOnce( - submission.promise, - ); - const first = renderHook(() => useCreateThreadQueuedMessage("thread-1"), { - wrapper, - }); + const submission = createDeferredPromise(); + if (kind === "send") { + vi.mocked(sdk.threads.send).mockImplementationOnce(async () => { + await submission.promise; + return { ok: true, delivery: "sent" }; + }); + } else { + vi.mocked(sdk.threads.queuedMessages.create).mockImplementationOnce( + async () => { + await submission.promise; + return makeQueuedMessage(); + }, + ); + } + const useSubmission = + kind === "send" ? useSendThreadMessage : useCreateThreadQueuedMessage; + const sendRequest = + kind === "send" ? sdk.threads.send : sdk.threads.queuedMessages.create; + const first = renderHook(() => useSubmission("thread-1"), { wrapper }); const request = { id: "thread-1", + mode: "auto" as const, input: [ { type: "text" as const, text: "Keep this draft", mentions: [] }, ], @@ -497,32 +515,19 @@ describe("thread runtime mutations", () => { act(() => { void first.result.current.mutateAsync(request).catch(() => undefined); }); - await waitFor(() => - expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), - ); + await waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); first.unmount(); - const reopened = renderHook( - () => useCreateThreadQueuedMessage("thread-1"), - { - wrapper, - }, - ); - const unrelated = renderHook( - () => useCreateThreadQueuedMessage("thread-2"), - { - wrapper, - }, - ); + const reopened = renderHook(() => useSubmission("thread-1"), { wrapper }); + const unrelated = renderHook(() => useSubmission("thread-2"), { + wrapper, + }); expect(reopened.result.current.isPending).toBe(true); expect(unrelated.result.current.isPending).toBe(false); await act(async () => { - if (outcome === "accepted") { - submission.resolve(makeQueuedMessage()); - } else { - submission.reject(new TypeError("Failed to fetch")); - } + if (outcome === "accepted") submission.resolve(); + else submission.reject(new TypeError("Failed to fetch")); }); await waitFor(() => expect(reopened.result.current.isPending).toBe(false), @@ -530,7 +535,7 @@ describe("thread runtime mutations", () => { await act(async () => { await reopened.result.current.mutateAsync(request); }); - expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(2); + expect(sendRequest).toHaveBeenCalledTimes(2); }, ); diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts index fcd338423b2..1f17d5b1079 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts @@ -166,10 +166,27 @@ export function useCreateThread() { }); } -export function useSendThreadMessage() { +const sendThreadMessageMutationKey = ["send-thread-message"]; + +export function useSendThreadMessage(threadId?: string) { const queryClient = useQueryClient(); - return useMutation({ + const pendingCount = useIsMutating({ + mutationKey: sendThreadMessageMutationKey, + predicate: ({ state }) => { + const variables = state.variables; + return ( + threadId !== undefined && + typeof variables === "object" && + variables !== null && + "id" in variables && + variables.id === threadId + ); + }, + }); + + const mutation = useMutation({ + mutationKey: sendThreadMessageMutationKey, meta: { errorMessage: "Failed to send message.", lifecycleOperation: "send_message", @@ -225,6 +242,8 @@ export function useSendThreadMessage() { }); }, }); + + return { ...mutation, isPending: mutation.isPending || pendingCount > 0 }; } export function useEditThreadMessage() { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index ac5aba58ae1..9bafcc33b7b 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -1011,6 +1011,39 @@ describe("ThreadDetailPromptArea", () => { }, ); + it.each(["accepted", "failed"])( + "retains a direct-send draft until the request is %s", + async (outcome) => { + const submission = createDeferredPromise<{ + ok: true; + delivery: "sent"; + }>(); + mocks.sendMessageMutateAsync.mockReturnValueOnce(submission.promise); + mocks.promptDraft.text = "Keep this direct message safe"; + const submittedDraft = mocks.promptDraft.getCurrent(); + renderPromptArea({ + thread: makeThread({ + status: "idle", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + }), + }); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + expect(mocks.sendMessageMutateAsync).toHaveBeenCalledTimes(1); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + await act(async () => { + if (outcome === "accepted") + submission.resolve({ ok: true, delivery: "sent" }); + else submission.reject(new TypeError("Failed to fetch")); + }); + if (outcome === "accepted") + expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledWith( + submittedDraft, + ); + else + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + }, + ); + it("preserves plugin submission data through a follow-up composer", async () => { mocks.defaultExecutionOptions = { model: "gpt-5", diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index be27eac28cd..26527210c86 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1007,23 +1007,15 @@ export function ThreadDetailPromptArea({ ...baseRequest, ...(pluginSubmission === undefined ? {} : { pluginSubmission }), }; - const clearedSubmittedDraft = - promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); - try { - const created = await createThread.mutateAsync(request); - navigate( - getThreadRoutePath({ - projectId: created.projectId, - threadId: created.id, - }), - ); - } catch (error) { - if (clearedSubmittedDraft) { - promptDraft.restoreIfEmpty(submittedDraft); - } - throw error; - } + const created = await createThread.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); + navigate( + getThreadRoutePath({ + projectId: created.projectId, + threadId: created.id, + }), + ); return true; }, [ @@ -1042,7 +1034,7 @@ export function ThreadDetailPromptArea({ ); const handleSend = useCallback(async () => { - if (createQueuedMessage.isPending) { + if (isFollowUpSubmitting) { return; } const submittedDraft = currentPromptDraft; @@ -1061,9 +1053,6 @@ export function ThreadDetailPromptArea({ return; } - if (!isQueuingMessage) { - promptDraft.clearIfCurrentMatches(submittedDraft); - } setBottomAttachmentError(null); try { @@ -1085,10 +1074,10 @@ export function ThreadDetailPromptArea({ }); if (request) { await sendMessage.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); } } } catch (nextError) { - promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error: nextError, fallbackMessage: isQueuingMessage @@ -1104,6 +1093,7 @@ export function ThreadDetailPromptArea({ currentPromptDraftInput, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isFollowUpSubmitting, isHandoffSelection, promptDraft, sendMessage, @@ -1116,6 +1106,9 @@ export function ThreadDetailPromptArea({ submitOptions: ExperimentalComposerSubmitOptions, pluginSubmission: SendMessageRequest["pluginSubmission"], ) => { + if (isFollowUpSubmitting) { + throw new Error("A message is still being submitted."); + } if (isHandoffSelection) { if (effectiveSelectedModel.length === 0) { throw new Error("The selected model is still loading."); @@ -1153,8 +1146,6 @@ export function ThreadDetailPromptArea({ if (request === null) { throw new Error("Type a message before submitting it."); } - const clearedSubmittedDraft = - promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); try { await sendMessage.mutateAsync({ @@ -1164,10 +1155,8 @@ export function ThreadDetailPromptArea({ : { sendAt: submitOptions.sendAt }), ...(pluginSubmission === undefined ? {} : { pluginSubmission }), }); + promptDraft.clearIfCurrentMatches(submittedDraft); } catch (scheduleError) { - if (clearedSubmittedDraft) { - promptDraft.restoreIfEmpty(submittedDraft); - } throw new Error( getMutationErrorMessage({ error: scheduleError, @@ -1182,6 +1171,7 @@ export function ThreadDetailPromptArea({ effectiveSelectedModel, followUpExecutionSelection, isDefaultExecutionOptionsLoading, + isFollowUpSubmitting, isHandoffSelection, promptDraft, sendMessage, @@ -1211,15 +1201,14 @@ export function ThreadDetailPromptArea({ } if (shortcutRequest.kind === "draft") { - promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); await runWhileFollowUpShortcutSending( setIsFollowUpShortcutSending, async () => { try { await sendMessage.mutateAsync(shortcutRequest.request); + promptDraft.clearIfCurrentMatches(submittedDraft); } catch (nextError) { - promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error: nextError, fallbackMessage: "Failed to send message", diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index b0604c451b7..d92ebb414e6 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -877,7 +877,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { } = useThreadTimelineController({ threadId, }); - const sendMessage = useSendThreadMessage(); + const sendMessage = useSendThreadMessage(threadId); const editMessage = useEditThreadMessage(); const createQueuedMessage = useCreateThreadQueuedMessage(); const requestEnvironmentAction = useRequestEnvironmentAction(); From 4437488afecde2be4fddb7c902b8a02cba4d1d32 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 22:43:48 -0400 Subject: [PATCH 06/29] Avoid restoring submitted text when the editor unlocks --- .../promptbox/PromptBoxInternal.test.tsx | 26 +++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 5417a62aa8f..8ab3ce6786b 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1264,6 +1264,32 @@ describe("PromptBoxInternal submit shortcuts", () => { expect(onRemove).toHaveBeenCalledWith("notes.txt"); }); + it("does not restore submitted text when acceptance clears and unlocks the editor", async () => { + const onChange = vi.fn(); + const props = createPromptBoxProps({ value: "Accepted message", onChange }); + const view = render( + , + ); + await waitFor(() => + expect(getPromptEditorElement().getAttribute("contenteditable")).toBe( + "false", + ), + ); + onChange.mockClear(); + view.rerender( + , + ); + await waitFor(() => expect(getPromptEditorElement().textContent).toBe("")); + expect(getPromptEditorElement().getAttribute("contenteditable")).toBe( + "true", + ); + expect(onChange).not.toHaveBeenCalled(); + }); + it("prevents history recall while submitting and restores it when settled", async () => { const onSelectEntry = vi.fn(); const promptBoxRef = createRef(); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 80c29bfd661..d92e87acbaf 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1880,7 +1880,7 @@ export function PromptBoxInternal({ useLayoutEffect(() => { if (!editor || editor.isDestroyed) return; const editable = !composerInputLocked && !showVoiceActionGroup; - if (editor.isEditable !== editable) editor.setEditable(editable); + if (editor.isEditable !== editable) editor.setEditable(editable, false); editor.view.dom.tabIndex = editable ? 0 : -1; if (editable) { editor.view.dom.removeAttribute("aria-readonly"); From f6c16bcf33bd49a62beca1af02307779a65304e4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:06:06 -0400 Subject: [PATCH 07/29] Persist message submissions and safely retry after reconnecting --- apps/app/package.json | 1 + apps/app/src/App.tsx | 2 + .../promptbox/FollowUpPromptBox.tsx | 4 +- .../banner/QueuedMessagesList.test.tsx | 83 +- .../promptbox/banner/QueuedMessagesList.tsx | 109 +- .../embedded-chat/EmbeddedThreadChat.test.tsx | 126 +- .../embedded-chat/EmbeddedThreadChat.tsx | 96 +- .../useInlineQueuedMessageEditing.test.ts | 182 + .../useInlineQueuedMessageEditing.ts | 172 +- .../useQueuedMessageActions.test.ts | 187 + .../embedded-chat/useQueuedMessageActions.ts | 129 +- .../thread-runtime-cache-owner.test.ts | 54 + .../thread-runtime-cache-owner.ts | 12 + .../useDurableMessageSubmission.test.tsx | 115 + .../src/hooks/useDurableMessageSubmission.ts | 83 + .../src/hooks/usePromptDraftStorage.test.tsx | 123 + apps/app/src/hooks/usePromptDraftStorage.ts | 105 +- apps/app/src/hooks/useQueuedMessageRows.ts | 17 + .../message-delivery/MessageDeliverySync.tsx | 159 + .../lib/message-delivery/coordinator.test.ts | 217 + .../src/lib/message-delivery/coordinator.ts | 302 + .../src/lib/message-delivery/store.test.ts | 319 ++ apps/app/src/lib/message-delivery/store.ts | 810 +++ .../useThreadMessageSubmissions.ts | 10 + apps/app/src/lib/queued-message-rows.test.ts | 135 + apps/app/src/lib/queued-message-rows.ts | 87 + .../ThreadDetailPromptArea.test.tsx | 131 +- .../thread-detail/ThreadDetailPromptArea.tsx | 128 +- .../command-output/thread-tell.test.ts | 43 + apps/cli/src/commands/thread/actions.ts | 15 +- apps/cli/src/commands/thread/organization.ts | 8 + apps/server/src/routes/system.ts | 5 +- .../src/services/threads/dispatch-attempt.ts | 91 +- .../src/services/threads/queue-waits.ts | 41 +- .../src/services/threads/queued-messages.ts | 128 +- .../services/threads/thread-provisioning.ts | 81 +- .../threads/thread-queued-messages.ts | 4 + .../services/threads/thread-send-request.ts | 29 +- .../src/services/threads/thread-send.ts | 90 +- .../threads/thread-submission-receipts.ts | 250 + .../services/threads/thread-turn-dispatch.ts | 3 + .../test/system/general-settings.test.ts | 1 + .../test/threads/dispatch-hooks.test.ts | 41 + .../thread-submission-receipts.test.ts | 558 ++ .../test/threads/thread-turn-dispatch.test.ts | 64 + .../db/drizzle/0126_glamorous_ultimates.sql | 12 + packages/db/drizzle/meta/0126_snapshot.json | 5054 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/data/queued-thread-messages.ts | 2 + packages/db/src/schema.ts | 18 + packages/domain/src/feature-flags.ts | 1 + packages/domain/src/thread.ts | 4 + packages/sdk/src/areas/threads.ts | 27 +- packages/sdk/test/sdk.test.ts | 40 + packages/server-contract/src/api/threads.ts | 16 +- .../server-contract/test/contract.test.ts | 39 +- .../src/templates/bb-guide-threads.md | 20 +- .../bb-cli/references/thread-operation.md | 10 + pnpm-lock.yaml | 9 + 59 files changed, 10308 insertions(+), 301 deletions(-) create mode 100644 apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts create mode 100644 apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts create mode 100644 apps/app/src/hooks/useDurableMessageSubmission.test.tsx create mode 100644 apps/app/src/hooks/useDurableMessageSubmission.ts create mode 100644 apps/app/src/hooks/useQueuedMessageRows.ts create mode 100644 apps/app/src/lib/message-delivery/MessageDeliverySync.tsx create mode 100644 apps/app/src/lib/message-delivery/coordinator.test.ts create mode 100644 apps/app/src/lib/message-delivery/coordinator.ts create mode 100644 apps/app/src/lib/message-delivery/store.test.ts create mode 100644 apps/app/src/lib/message-delivery/store.ts create mode 100644 apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts create mode 100644 apps/app/src/lib/queued-message-rows.test.ts create mode 100644 apps/app/src/lib/queued-message-rows.ts create mode 100644 apps/server/src/services/threads/thread-submission-receipts.ts create mode 100644 apps/server/test/threads/thread-submission-receipts.test.ts create mode 100644 packages/db/drizzle/0126_glamorous_ultimates.sql create mode 100644 packages/db/drizzle/meta/0126_snapshot.json diff --git a/apps/app/package.json b/apps/app/package.json index 7dd55fc96b7..c5e2ba588b7 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -114,6 +114,7 @@ "babel-plugin-react-compiler": "^1.0.0", "bb-plugin-automations": "workspace:*", "cacache": "^20.0.4", + "fake-indexeddb": "^6.2.5", "lightningcss": "^1.32.0", "mdast-util-to-hast": "^13.2.1", "oniguruma-to-es": "^4.3.4", diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 4bc8707e8c0..514596d3e5e 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -15,6 +15,7 @@ import { RouteNavigationIndicator } from "./components/ui/route-navigation-indic import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; +import { MessageDeliverySync } from "@/lib/message-delivery/MessageDeliverySync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -433,6 +434,7 @@ export function App() { + { expect(queryByLabelText("Delete queued message 1")).not.toBeNull(); }); }); + +describe("local message delivery rows", () => { + function localRow(editable: boolean): LocalQueuedMessageRow { + return { + source: "local", + id: "local-pending", + threadId: "thr_prompt_pills", + clientSubmissionId: "submission-pending", + content: [{ type: "text", text: "Saved before reconnect", mentions: [] }], + model: undefined, + reasoningLevel: undefined, + permissionMode: undefined, + serviceTier: undefined, + initiator: "user", + senderThreadId: null, + groupWithNext: false, + createdAt: 1, + updatedAt: 1, + editable, + deliveryStatus: editable ? "waiting" : "confirming", + error: null, + }; + } + + it("keeps Send now visible but disabled while an uncertain row blocks editing and deletion", () => { + const { getByRole, getByText } = renderQueuedMessages([localRow(false)]); + expect(getByText("Confirming delivery")).toBeTruthy(); + expect( + getByRole("button", { name: "Send queued message 1 now" }).hasAttribute( + "disabled", + ), + ).toBe(true); + expect( + getByRole("button", { name: "Edit queued message 1" }).hasAttribute( + "disabled", + ), + ).toBe(true); + expect( + getByRole("button", { name: "Delete queued message 1" }).hasAttribute( + "disabled", + ), + ).toBe(true); + }); + + it("uses the existing actions for unattempted rows and disables grouping across server and local rows", () => { + const { getByRole, getByText } = renderQueuedMessages([ + makeQueuedMessage("server-one", "Server queued"), + localRow(true), + ]); + expect(getByText("Waiting for connection")).toBeTruthy(); + expect( + getByRole("button", { name: "Send queued message 2 now" }).hasAttribute( + "disabled", + ), + ).toBe(true); + expect( + getByRole("button", { name: "Edit queued message 2" }).hasAttribute( + "disabled", + ), + ).toBe(false); + expect( + getByRole("button", { name: "Delete queued message 2" }).hasAttribute( + "disabled", + ), + ).toBe(false); + expect( + getByRole("button", { name: "Reorder queued message 1" }).hasAttribute( + "disabled", + ), + ).toBe(true); + expect( + getByRole("button", { + name: "Messages above send together", + }).hasAttribute("disabled"), + ).toBe(true); + }); +}); diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index d59f8d45f92..333ce0fb8dd 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -55,6 +55,11 @@ import type { PromptTextMention, ThreadQueuedMessage, } from "@bb/domain"; +import { + getServerQueuedMessages, + isLocalQueuedMessage, + type QueuedMessageRow as QueuedMessageRowData, +} from "@/lib/queued-message-rows"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { @@ -120,7 +125,7 @@ export interface QueuedMessageInlineEditor { export interface QueuedMessagesListProps { attachedToComposer: boolean; - queuedMessages: readonly ThreadQueuedMessage[]; + queuedMessages: readonly QueuedMessageRowData[]; resolveMentionLink?: PromptMentionLinkResolver; sendAction: QueuedMessageSendAction; sendDisabled: boolean; @@ -146,7 +151,7 @@ interface QueuedMessagePreviewText { interface QueuedMessageRowProps { senderLabel: string | null; - queuedMessage: ThreadQueuedMessage; + queuedMessage: QueuedMessageRowData; resolveMentionLink?: PromptMentionLinkResolver; index: number; isProcessing: boolean; @@ -185,7 +190,7 @@ function getDrawerHeight({ queuedMessages, processingMessageId, }: { - queuedMessages: readonly ThreadQueuedMessage[]; + queuedMessages: readonly QueuedMessageRowData[]; processingMessageId: string | null; }): number { const rowsHeight = @@ -196,6 +201,7 @@ function getDrawerHeight({ total + DRAWER_ROW_HEIGHT + (queuedMessage.initiator !== "user" || + isLocalQueuedMessage(queuedMessage) || queuedMessageHasWaitLine(queuedMessage) || queuedMessage.id === processingMessageId ? queuedMessage.initiator === "agent" && @@ -614,7 +620,7 @@ function QueuedMessageFallbackTitle({ queuedMessage, }: { compact: boolean; - queuedMessage: ThreadQueuedMessage; + queuedMessage: Exclude; }) { const now = useSecondTick(); const title = queuedMessageFallbackTitle({ @@ -635,7 +641,7 @@ function QueuedMessagePreview({ resolveMentionLink, }: { compact: boolean; - queuedMessage: ThreadQueuedMessage; + queuedMessage: QueuedMessageRowData; resolveMentionLink?: PromptMentionLinkResolver; }) { const preview = useMemo( @@ -650,7 +656,10 @@ function QueuedMessagePreview({ [queuedMessage.content], ); - if (queuedMessage.payload.kind === "retry") { + if ( + !isLocalQueuedMessage(queuedMessage) && + queuedMessage.payload.kind === "retry" + ) { return (
; }) { const now = useSecondTick(); const label = describeQueuedMessageWait({ @@ -778,21 +787,27 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ () => countQueuedMessageAttachments(queuedMessage.content), [queuedMessage.content], ); + const local = isLocalQueuedMessage(queuedMessage); const pluginDisplayName = usePluginDisplayName( - queuedMessage.waitingOn?.kind === "plugin" + !local && queuedMessage.waitingOn?.kind === "plugin" ? queuedMessage.waitingOn.pluginId : "", ); - const hasWaitLine = queuedMessageHasWaitLine(queuedMessage); + const hasWaitLine = + isLocalQueuedMessage(queuedMessage) || + queuedMessageHasWaitLine(queuedMessage); const sendAllowed = + local || sendAction === "steer-when-ready" || isQueuedMessageSendNowAllowed(queuedMessage.waitingOn); const sendAriaLabel = - sendAction === "steer-when-ready" + !local && sendAction === "steer-when-ready" ? `Steer queued message ${index + 1} when ready` : `Send queued message ${index + 1} now`; const sendLabel = - sendAction === "steer-when-ready" ? "Steer when ready" : "Send now"; + !local && sendAction === "steer-when-ready" + ? "Steer when ready" + : "Send now"; const { attributes, isDragging, @@ -921,6 +936,39 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ) : null} {isProcessing ? ( + ) : local ? ( +
+ + + {queuedMessage.deliveryStatus === "rejected" + ? (queuedMessage.error ?? + "Message could not be delivered") + : queuedMessage.deliveryStatus === "waiting" + ? "Waiting for connection" + : "Confirming delivery"} + +
) : hasWaitLine ? ( onSend(queuedMessage.id)} + disabled={actionDisabled || sendDisabled || local} + onClick={() => { + if (!local) onSend(queuedMessage.id); + }} aria-label={sendAriaLabel} > @@ -969,7 +1019,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ) : null} - {queuedMessage.editable ? ( + {queuedMessage.editable || local ? ( @@ -403,6 +431,9 @@ function renderEmbeddedChat( describe("EmbeddedThreadChat", () => { beforeEach(() => { mocks.createQueuedMessageIsPending = false; + mocks.durableEnabled = false; + mocks.serverConnected = true; + mocks.durableEnqueue.mockReset().mockResolvedValue(undefined); window.localStorage.clear(); mocks.createQueuedMessageMutateAsync.mockReset().mockResolvedValue({}); mocks.sendThreadMessageMutateAsync.mockReset().mockResolvedValue({}); @@ -502,6 +533,97 @@ describe("EmbeddedThreadChat", () => { expect(mocks.injectedTimelineProps.at(-1)).toBeUndefined(); }); + it("disables idle Send while disconnected without locking the draft", () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + renderEmbeddedChat(); + const composer = screen.getByTestId( + "embedded-chat-composer", + ); + expect( + screen.getByRole("button", { name: "Send" }).disabled, + ).toBe(true); + expect(composer.readOnly).toBe(false); + fireEvent.change(composer, { target: { value: "Keep writing offline" } }); + expect(composer.value).toBe("Keep writing offline"); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expect(mocks.durableEnqueue).not.toHaveBeenCalled(); + expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); + }); + + it("hands an offline busy-thread submission to durable storage and permits the next draft after local save", async () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + mocks.threadRuntimeDisplayStatus = "active"; + const saved = createDeferredPromise(); + mocks.durableEnqueue.mockReturnValueOnce(saved.promise); + renderEmbeddedChat(); + fireEvent.change(screen.getByTestId("embedded-chat-composer"), { + target: { value: "Deliver after reconnect" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expect(mocks.durableEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "queue", + request: expect.objectContaining({ + input: [ + { type: "text", text: "Deliver after reconnect", mentions: [] }, + ], + }), + }), + expect.objectContaining({ + storageKey: expect.any(String), + value: { + text: "Deliver after reconnect", + mentions: [], + attachments: [], + }, + }), + ); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Deliver after reconnect"); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); + await act(async () => { + getPromptDraftAccessor({ + kind: "thread", + projectId: "proj-1", + threadId: "thr_child", + }).setDraft({ text: "", mentions: [], attachments: [] }); + saved.resolve(); + }); + const composer = screen.getByTestId( + "embedded-chat-composer", + ); + expect(composer.readOnly).toBe(false); + fireEvent.change(composer, { target: { value: "The next draft" } }); + expect(composer.value).toBe("The next draft"); + }); + + it("retains an offline queued draft when durable persistence fails", async () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + mocks.threadRuntimeDisplayStatus = "active"; + mocks.durableEnqueue.mockRejectedValueOnce( + new Error("Message storage is full"), + ); + renderEmbeddedChat(); + fireEvent.change(screen.getByTestId("embedded-chat-composer"), { + target: { value: "Must stay in the prompt" }, + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Send" })); + }); + const composer = screen.getByTestId( + "embedded-chat-composer", + ); + expect(composer.value).toBe("Must stay in the prompt"); + expect(composer.readOnly).toBe(false); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); + }); + it("queues the submitted draft itself while the thread runtime is active", async () => { mocks.threadRuntimeDisplayStatus = "active"; renderEmbeddedChat(); diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 349566df7ca..48f874ed2a7 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -73,6 +73,11 @@ import { } from "@bb/client-core"; import { useActiveComposerDraft } from "./useActiveComposerDraft"; import { useComposerAttachmentUploads } from "./useComposerAttachmentUploads"; +import { useQueuedMessageRows } from "@/hooks/useQueuedMessageRows"; +import { + isDurableMessageInput, + useDurableMessageSubmission, +} from "@/hooks/useDurableMessageSubmission"; import { useLatestRef } from "@/hooks/useLatestRef"; import { useComposerTypeahead } from "./useComposerTypeahead"; import { useInlineQueuedMessageEditing } from "./useInlineQueuedMessageEditing"; @@ -261,7 +266,9 @@ function EmbeddedThreadChatWithComposer({ markThreadRead, thread: threadQuery.data, }); - const { data: queuedMessages = [] } = useThreadQueuedMessages(threadId); + const { data: serverQueuedMessages = [] } = useThreadQueuedMessages(threadId); + const queuedMessages = useQueuedMessageRows(threadId, serverQueuedMessages); + const durableSubmission = useDurableMessageSubmission(threadId); const executionOptionsQuery = useThreadDefaultExecutionOptions( composer.executionDefaultsThreadId, @@ -493,6 +500,9 @@ function EmbeddedThreadChatWithComposer({ const submittedInput = currentPromptDraftInput; if ( submittedInput.length === 0 || + durableSubmission.isSaving || + (!durableSubmission.connected && + !shouldQueueFollowUpMessage(displayStatus)) || isTurnSubmitting || sendThreadMessage.isPending || createQueuedMessage.isPending @@ -501,9 +511,29 @@ function EmbeddedThreadChatWithComposer({ } setBottomAttachmentError(null); setIsTurnSubmitting(true); - void defaultSendOrQueueInput(submittedInput) + const durablyQueued = + durableSubmission.enabled && isDurableMessageInput(submittedInput); + const submission = durablyQueued + ? durableSubmission.enqueue( + shouldQueueFollowUpMessage(displayStatus) + ? { + kind: "queue", + request: { input: submittedInput, ...executionRequestFields }, + } + : { + kind: "send", + request: { + input: submittedInput, + mode: "queue-if-active", + ...executionRequestFields, + }, + }, + { storageKey: promptDraft.storageKey, value: submittedDraft }, + ) + : defaultSendOrQueueInput(submittedInput); + void submission .then(() => { - promptDraft.clearIfCurrentMatches(submittedDraft); + if (!durablyQueued) promptDraft.clearIfCurrentMatches(submittedDraft); }) .catch((error) => { if (!isMountedRef.current) { @@ -527,6 +557,8 @@ function EmbeddedThreadChatWithComposer({ currentPromptDraft, currentPromptDraftInput, defaultSendOrQueueInput, + durableSubmission, + executionRequestFields, displayStatus, isTurnSubmitting, sendThreadMessage.isPending, @@ -547,14 +579,19 @@ function EmbeddedThreadChatWithComposer({ [isProvisioning, sendQueuedMessageById], ); const hasPromptDraftInput = currentPromptDraftInput.length > 0; - const canSubmitModifierShortcut = canSubmitFollowUpShortcut({ - hasPromptDraftInput, - isFollowUpSubmitting: isTurnSubmitting || sendThreadMessage.isPending, - isQueueMutationPending, - queuedMessageCount: queuedMessages.length, - runtimeDisplayStatus: displayStatus, - submitModeKind: submitMode.kind, - }); + const canSubmitModifierShortcut = + durableSubmission.connected && + canSubmitFollowUpShortcut({ + hasPromptDraftInput, + isFollowUpSubmitting: + durableSubmission.isSaving || + isTurnSubmitting || + sendThreadMessage.isPending, + isQueueMutationPending, + queuedMessageCount: queuedMessages.length, + runtimeDisplayStatus: displayStatus, + submitModeKind: submitMode.kind, + }); const handleModifierSubmit = useCallback(() => { if (!canSubmitModifierShortcut) { return; @@ -806,6 +843,7 @@ function EmbeddedThreadChatWithComposer({ onSelectEntry: promptDraft.setDraft, } satisfies HistoryConfig, isFollowUpSubmitting: + durableSubmission.isSaving || isTurnSubmitting || sendThreadMessage.isPending || createQueuedMessage.isPending, @@ -817,7 +855,15 @@ function EmbeddedThreadChatWithComposer({ compactPromptPlaceholder: composerPlaceholder, promptPlaceholder: composerPlaceholder, canModifierSubmit: canSubmitModifierShortcut, - steerActiveThreadOnEnter, + steerActiveThreadOnEnter: + durableSubmission.connected && steerActiveThreadOnEnter, + submitDisabled: + !durableSubmission.connected && + !shouldQueueFollowUpMessage(displayStatus), + ...(!durableSubmission.connected && + !shouldQueueFollowUpMessage(displayStatus) + ? { submitTitle: "Waiting for connection" } + : {}), submitMode, threadRuntimeDisplayStatus: displayStatus, }), @@ -825,8 +871,10 @@ function EmbeddedThreadChatWithComposer({ canSubmitModifierShortcut, composerPlaceholder, createQueuedMessage.isPending, - currentPromptDraft, + durableSubmission.connected, + durableSubmission.isSaving, displayStatus, + currentPromptDraft, handleModifierSubmit, handleSubmit, isTurnSubmitting, @@ -973,11 +1021,15 @@ function EmbeddedThreadChatWithComposer({ ...bottomExecutionConfig, model: { ...bottomExecutionConfig.model, - active: { model: inlineEditingQueuedMessage.model }, - selected: inlineEditingQueuedMessage.model, + active: inlineEditingQueuedMessage.model + ? { model: inlineEditingQueuedMessage.model } + : undefined, + selected: inlineEditingQueuedMessage.model ?? "", }, serviceTier: { - value: inlineEditingQueuedMessage.serviceTier, + value: + inlineEditingQueuedMessage.serviceTier ?? + bottomExecutionConfig.serviceTier?.value, onChange: setServiceTier, supported: supportsServiceTier, supportByProvider: serviceTierSupportByProvider, @@ -985,7 +1037,9 @@ function EmbeddedThreadChatWithComposer({ }, reasoning: { ...bottomExecutionConfig.reasoning, - value: inlineEditingQueuedMessage.reasoningLevel, + value: + inlineEditingQueuedMessage.reasoningLevel ?? + bottomExecutionConfig.reasoning.value, }, } : null, @@ -1087,14 +1141,17 @@ function EmbeddedThreadChatWithComposer({ const queuedMessagesStack = useMemo( () => - queuedMessages.length > 0 && !pendingInteractionOccupiesComposer ? ( + (queuedMessages.length > 0 || inlineEditor !== undefined) && + !pendingInteractionOccupiesComposer ? ( ) : null, [ + durableSubmission.connected, beginEditQueuedMessage, handleDeleteQueuedMessage, handleReorderQueuedMessage, diff --git a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts new file mode 100644 index 00000000000..86c2257b73d --- /dev/null +++ b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + LocalQueuedMessageRow, + QueuedMessageRow, +} from "@/lib/queued-message-rows"; +import type { SubmissionEditClaim } from "@/lib/message-delivery/store"; +import { useInlineQueuedMessageEditing } from "./useInlineQueuedMessageEditing"; + +const store = vi.hoisted(() => ({ + acquire: vi.fn(), + renew: vi.fn().mockResolvedValue(true), + release: vi.fn().mockResolvedValue(undefined), + toast: vi.fn(), +})); + +vi.mock("@/lib/message-delivery/store", () => ({ + acquireSubmissionEdit: store.acquire, + renewSubmissionEdit: store.renew, + releaseSubmissionEdit: store.release, +})); +vi.mock("@/lib/mutation-errors", () => ({ + showMutationErrorToast: store.toast, +})); + +const row: LocalQueuedMessageRow = { + source: "local", + id: "local-one", + clientSubmissionId: "submission-one", + threadId: "thread-one", + content: [{ type: "text", text: "Original message", mentions: [] }], + model: undefined, + reasoningLevel: undefined, + permissionMode: undefined, + serviceTier: undefined, + createdAt: 1, + updatedAt: 2, + editable: true, + groupWithNext: false, + initiator: "user", + senderThreadId: null, + deliveryStatus: "waiting", + error: null, +}; +const claim: SubmissionEditClaim = { + threadId: row.threadId, + id: row.id, + token: "edit-token", + expiresAt: 45000, +}; + +function renderEditor() { + return renderHook( + ({ messages }: { messages: readonly QueuedMessageRow[] }) => + useInlineQueuedMessageEditing({ + ownerThreadId: row.threadId, + queuedMessages: messages, + }), + { initialProps: { messages: [row] } }, + ); +} + +async function beginEditor(result: ReturnType["result"]) { + await act(async () => { + result.current.beginEditQueuedMessage({ + queuedMessageId: row.id, + queuedMessageIndex: 0, + }); + }); +} + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("local queued-message editing claim", () => { + it("acquires the delivery pause before opening and renews it while reconnect updates the row", async () => { + vi.useFakeTimers(); + let resolveClaim: (value: SubmissionEditClaim) => void = () => undefined; + store.acquire.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveClaim = resolve; + }), + ); + const { result, rerender } = renderEditor(); + act(() => + result.current.beginEditQueuedMessage({ + queuedMessageId: row.id, + queuedMessageIndex: 0, + }), + ); + expect(result.current.inlineEditingQueuedMessage).toBeNull(); + await act(async () => { + resolveClaim(claim); + }); + expect(store.acquire).toHaveBeenCalledWith({ + threadId: row.threadId, + id: row.id, + expectedUpdatedAt: row.updatedAt, + }); + act(() => + result.current.queuedMessageDraftSession?.setDraft((draft) => ({ + ...draft, + text: "Unsaved correction", + })), + ); + rerender({ messages: [{ ...row, deliveryStatus: "confirming" }] }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(store.renew).toHaveBeenCalledWith(claim); + expect(result.current.inlineEditingQueuedMessage?.draft.text).toBe( + "Unsaved correction", + ); + act(() => result.current.dismissInlineQueuedMessageEditor()); + expect(store.release).toHaveBeenCalledWith(claim); + expect(result.current.inlineEditingQueuedMessage).toBeNull(); + }); + + it("preserves unsaved corrections when a suspended tab loses its claim and the original leaves the queue", async () => { + vi.useFakeTimers(); + store.acquire.mockResolvedValueOnce(claim); + store.renew.mockResolvedValueOnce(false); + const { result, rerender, unmount } = renderEditor(); + await beginEditor(result); + act(() => + result.current.queuedMessageDraftSession?.setDraft((draft) => ({ + ...draft, + text: "Do not discard this", + })), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + rerender({ messages: [] }); + expect(result.current.inlineEditingQueuedMessage?.draft.text).toBe( + "Do not discard this", + ); + expect(store.toast).toHaveBeenCalledOnce(); + unmount(); + expect(store.release).toHaveBeenCalledWith(claim); + }); + + it("does not open an editor when delivery has already claimed the message", async () => { + store.acquire.mockRejectedValueOnce(new Error("Already being delivered")); + const { result } = renderEditor(); + await beginEditor(result); + expect(result.current.inlineEditingQueuedMessage).toBeNull(); + expect(store.toast).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackMessage: "Failed to edit queued message", + }), + ); + }); + + it("releases an acquired claim if the composer unmounts while acquisition is pending", async () => { + let resolveClaim: (value: SubmissionEditClaim) => void = () => undefined; + store.acquire.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveClaim = resolve; + }), + ); + const { result, unmount } = renderEditor(); + act(() => + result.current.beginEditQueuedMessage({ + queuedMessageId: row.id, + queuedMessageIndex: 0, + }), + ); + unmount(); + await act(async () => { + resolveClaim(claim); + }); + expect(store.release).toHaveBeenCalledWith(claim); + }); +}); diff --git a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts index 5755c1e7f2a..85abd38b041 100644 --- a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts +++ b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts @@ -1,5 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ThreadQueuedMessage } from "@bb/domain"; +import { + isLocalQueuedMessage, + type QueuedMessageRow, +} from "@/lib/queued-message-rows"; +import { + acquireSubmissionEdit, + releaseSubmissionEdit, + renewSubmissionEdit, + type SubmissionEditClaim, +} from "@/lib/message-delivery/store"; +import { showMutationErrorToast } from "@/lib/mutation-errors"; import type { QueuedMessageEditRequest } from "@/components/promptbox/banner/QueuedMessagesList"; import type { PromptDraftState } from "@bb/client-core"; import { queuedInputToDraft } from "@bb/client-core"; @@ -9,18 +19,19 @@ export interface InlineQueuedMessageEditState { draft: PromptDraftState; editSessionId: number; expectedUpdatedAt: number; - model: ThreadQueuedMessage["model"]; + localEditClaim?: SubmissionEditClaim; + model: QueuedMessageRow["model"]; ownerThreadId: string; - permissionMode: ThreadQueuedMessage["permissionMode"]; + permissionMode: QueuedMessageRow["permissionMode"]; queuedMessageId: string; queuedMessageIndex: number; - reasoningLevel: ThreadQueuedMessage["reasoningLevel"]; - serviceTier: ThreadQueuedMessage["serviceTier"]; + reasoningLevel: QueuedMessageRow["reasoningLevel"]; + serviceTier: QueuedMessageRow["serviceTier"]; } interface UseInlineQueuedMessageEditingArgs { ownerThreadId: string; - queuedMessages: readonly ThreadQueuedMessage[]; + queuedMessages: readonly QueuedMessageRow[]; onBeginEdit?: () => void; } @@ -50,12 +61,15 @@ export function useInlineQueuedMessageEditing({ const inlineEditingQueuedMessageRef = useRef(null); const inlineEditSessionIdRef = useRef(0); + const editRequestRef = useRef(0); + const currentOwnerRef = useRef(ownerThreadId); + currentOwnerRef.current = ownerThreadId; - const queuedMessagesByIdRef = useRef< - ReadonlyMap - >(new Map()); + const queuedMessagesByIdRef = useRef>( + new Map(), + ); queuedMessagesByIdRef.current = useMemo(() => { - const next = new Map(); + const next = new Map(); for (const message of queuedMessages) { next.set(message.id, message); } @@ -64,6 +78,14 @@ export function useInlineQueuedMessageEditing({ const commitInlineQueuedMessage = useCallback( (next: InlineQueuedMessageEditState | null) => { + const previousClaim = + inlineEditingQueuedMessageRef.current?.localEditClaim; + if ( + previousClaim && + previousClaim.token !== next?.localEditClaim?.token + ) { + void releaseSubmissionEdit(previousClaim).catch(() => undefined); + } inlineEditingQueuedMessageRef.current = next; setInlineEditingQueuedMessage(next); }, @@ -75,15 +97,12 @@ export function useInlineQueuedMessageEditing({ current: InlineQueuedMessageEditState | null, ) => InlineQueuedMessageEditState | null, ) => { - setInlineEditingQueuedMessage((current) => { - const next = updater(current); - inlineEditingQueuedMessageRef.current = next; - return next; - }); + commitInlineQueuedMessage(updater(inlineEditingQueuedMessageRef.current)); }, - [], + [commitInlineQueuedMessage], ); const dismissInlineQueuedMessageEditor = useCallback(() => { + editRequestRef.current += 1; commitInlineQueuedMessage(null); }, [commitInlineQueuedMessage]); @@ -91,10 +110,11 @@ export function useInlineQueuedMessageEditing({ () => inlineEditingQueuedMessageState !== null && inlineEditingQueuedMessageState.ownerThreadId === ownerThreadId && - queuedMessages.some( - (message) => - message.id === inlineEditingQueuedMessageState.queuedMessageId, - ) + (inlineEditingQueuedMessageState.localEditClaim !== undefined || + queuedMessages.some( + (message) => + message.id === inlineEditingQueuedMessageState.queuedMessageId, + )) ? inlineEditingQueuedMessageState : null, [inlineEditingQueuedMessageState, ownerThreadId, queuedMessages], @@ -112,25 +132,111 @@ export function useInlineQueuedMessageEditing({ inlineEditingQueuedMessageState, ]); + useEffect( + () => () => { + editRequestRef.current += 1; + const claim = inlineEditingQueuedMessageRef.current?.localEditClaim; + if (claim) void releaseSubmissionEdit(claim).catch(() => undefined); + }, + [], + ); + + const localEditClaim = inlineEditingQueuedMessage?.localEditClaim; + useEffect(() => { + if (!localEditClaim) return; + let stopped = false; + let renewing = false; + const reportFailure = (error: Error) => { + if (stopped) return; + stopped = true; + showMutationErrorToast({ + error, + fallbackMessage: + "The queued message is no longer available to edit. Your changes are still here.", + lifecycleOperation: "update_queued_message", + }); + }; + const interval = window.setInterval(() => { + if (stopped || renewing) return; + renewing = true; + void renewSubmissionEdit(localEditClaim) + .then((renewed) => { + if (!renewed) + reportFailure( + new Error( + "The queued message is no longer available to edit. Your changes are still here.", + ), + ); + }) + .catch(() => + reportFailure( + new Error( + "Could not keep this message paused for editing. Your changes are still here.", + ), + ), + ) + .finally(() => { + renewing = false; + }); + }, 10_000); + return () => { + stopped = true; + window.clearInterval(interval); + }; + }, [localEditClaim]); + const beginEditQueuedMessage = useCallback( ({ queuedMessageId, queuedMessageIndex }: QueuedMessageEditRequest) => { const queuedMessage = queuedMessagesByIdRef.current.get(queuedMessageId); - if (!queuedMessage) { + if (!queuedMessage || !queuedMessage.editable) return; + const requestId = ++editRequestRef.current; + const openEditor = (claim?: SubmissionEditClaim) => { + commitInlineQueuedMessage({ + draft: queuedInputToDraft(queuedMessage.content), + editSessionId: (inlineEditSessionIdRef.current += 1), + expectedUpdatedAt: queuedMessage.updatedAt, + localEditClaim: claim, + model: queuedMessage.model, + ownerThreadId, + permissionMode: queuedMessage.permissionMode, + queuedMessageId, + queuedMessageIndex, + reasoningLevel: queuedMessage.reasoningLevel, + serviceTier: queuedMessage.serviceTier, + }); + onBeginEdit?.(); + }; + if (!isLocalQueuedMessage(queuedMessage)) { + openEditor(); return; } - commitInlineQueuedMessage({ - draft: queuedInputToDraft(queuedMessage.content), - editSessionId: (inlineEditSessionIdRef.current += 1), + void acquireSubmissionEdit({ + threadId: ownerThreadId, + id: queuedMessageId, expectedUpdatedAt: queuedMessage.updatedAt, - model: queuedMessage.model, - ownerThreadId, - permissionMode: queuedMessage.permissionMode, - queuedMessageId, - queuedMessageIndex, - reasoningLevel: queuedMessage.reasoningLevel, - serviceTier: queuedMessage.serviceTier, - }); - onBeginEdit?.(); + }) + .then(async (claim) => { + if ( + editRequestRef.current !== requestId || + currentOwnerRef.current !== ownerThreadId + ) { + await releaseSubmissionEdit(claim); + return; + } + openEditor(claim); + }) + .catch((error) => { + if ( + editRequestRef.current !== requestId || + currentOwnerRef.current !== ownerThreadId + ) + return; + showMutationErrorToast({ + error, + fallbackMessage: "Failed to edit queued message", + lifecycleOperation: "update_queued_message", + }); + }); }, [commitInlineQueuedMessage, onBeginEdit, ownerThreadId], ); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts new file mode 100644 index 00000000000..48f68cd0c1c --- /dev/null +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; +import type { LocalQueuedMessageRow } from "@/lib/queued-message-rows"; +import { useQueuedMessageActions } from "./useQueuedMessageActions"; + +const actions = vi.hoisted(() => ({ + send: vi.fn().mockResolvedValue(undefined), + edit: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + reorder: vi.fn().mockResolvedValue(undefined), + group: vi.fn().mockResolvedValue(undefined), + editLocal: vi.fn().mockResolvedValue(undefined), + deleteLocal: vi.fn().mockResolvedValue(undefined), + toast: vi.fn(), +})); + +vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ + useSendThreadQueuedMessage: () => ({ + mutateAsync: actions.send, + isPending: false, + }), + useUpdateThreadQueuedMessage: () => ({ + mutateAsync: actions.edit, + isPending: false, + }), + useDeleteThreadQueuedMessage: () => ({ + mutateAsync: actions.delete, + isPending: false, + }), + useReorderThreadQueuedMessage: () => ({ + mutateAsync: actions.reorder, + isPending: false, + }), + useSetThreadQueuedMessageGroupBoundary: () => ({ + mutateAsync: actions.group, + isPending: false, + }), +})); +vi.mock("@/lib/message-delivery/store", () => ({ + editSubmission: actions.editLocal, + deleteSubmission: actions.deleteLocal, +})); +vi.mock("@/lib/mutation-errors", () => ({ + showMutationErrorToast: actions.toast, +})); + +function localRow(editable: boolean): LocalQueuedMessageRow { + return { + source: "local", + id: "local-one", + clientSubmissionId: "submission-one", + threadId: "thread-one", + content: [{ type: "text", text: "Saved message", mentions: [] }], + model: undefined, + reasoningLevel: undefined, + permissionMode: undefined, + serviceTier: undefined, + createdAt: 1, + updatedAt: 2, + editable, + groupWithNext: false, + initiator: "user", + senderThreadId: null, + deliveryStatus: editable ? "waiting" : "confirming", + error: null, + }; +} + +function renderActions(row: LocalQueuedMessageRow) { + return renderHook(() => + useQueuedMessageActions({ + threadId: row.threadId, + queuedMessages: [makeThreadQueuedMessage({ id: "server-one" }), row], + sendProcessingPersistence: "clear-on-settle", + inlineEditingQueuedMessage: { + draft: { text: "Saved message", mentions: [], attachments: [] }, + editSessionId: 1, + expectedUpdatedAt: row.updatedAt, + localEditClaim: row.editable + ? { + threadId: row.threadId, + id: row.id, + token: "edit-token", + expiresAt: 45000, + } + : undefined, + model: row.model, + ownerThreadId: row.threadId, + permissionMode: row.permissionMode, + queuedMessageId: row.id, + queuedMessageIndex: 1, + reasoningLevel: row.reasoningLevel, + serviceTier: row.serviceTier, + }, + dismissInlineQueuedMessageEditor: vi.fn(), + activeComposerDraftInput: [ + { type: "text", text: "Corrected message", mentions: [] }, + ], + }), + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("local queued-message action boundaries", () => { + it("never sends an unaccepted ID even with guard none, and blocks uncertain edits, deletes, and mixed reorders", async () => { + const row = localRow(false); + const { result } = renderActions(row); + await act(async () => { + await result.current.sendQueuedMessageById({ + guard: "none", + messageId: row.id, + mode: "auto", + }); + await result.current.handleSaveInlineQueuedMessage(); + result.current.handleDeleteQueuedMessage(row.id); + result.current.handleReorderQueuedMessage({ + queuedMessageId: "server-one", + previousQueuedMessageId: row.id, + nextQueuedMessageId: null, + }); + result.current.handleSetQueuedMessageGroupBoundary({ + groupBoundaryQueuedMessageId: row.id, + expectedGroupedPrefixQueuedMessageIds: ["server-one", row.id], + }); + }); + for (const action of [ + actions.send, + actions.edit, + actions.delete, + actions.reorder, + actions.group, + actions.editLocal, + actions.deleteLocal, + ]) + expect(action).not.toHaveBeenCalled(); + }); + + it("routes local edits and deletes through transactional storage without server mutations", async () => { + const row = localRow(true); + const { result } = renderActions(row); + await act(async () => { + await result.current.handleSaveInlineQueuedMessage(); + }); + expect(actions.editLocal).toHaveBeenCalledWith({ + threadId: row.threadId, + id: row.id, + expectedUpdatedAt: row.updatedAt, + input: [{ type: "text", text: "Corrected message", mentions: [] }], + editToken: "edit-token", + }); + act(() => result.current.handleDeleteQueuedMessage(row.id)); + await waitFor(() => + expect(result.current.queuedMessageActionPending).toBe(false), + ); + expect(actions.deleteLocal).toHaveBeenCalledWith({ + threadId: row.threadId, + id: row.id, + expectedUpdatedAt: row.updatedAt, + }); + expect(actions.edit).not.toHaveBeenCalled(); + expect(actions.delete).not.toHaveBeenCalled(); + }); + + it("keeps the inline edit available and reports a transaction race rather than falling through to the server", async () => { + actions.editLocal.mockRejectedValueOnce( + new Error("Delivery has already started"), + ); + const { result } = renderActions(localRow(true)); + await act(async () => { + await result.current.handleSaveInlineQueuedMessage(); + }); + expect(actions.toast).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackMessage: "Failed to update queued message", + }), + ); + expect(actions.edit).not.toHaveBeenCalled(); + expect(result.current.isUpdateQueuedMessagePending).toBe(false); + }); +}); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts index e55f063080b..7def7e0d7b7 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import type { PromptInput, ThreadQueuedMessage } from "@bb/domain"; +import type { PromptInput } from "@bb/domain"; import type { SendQueuedMessageMode } from "@bb/server-contract"; import type { QueuedMessageGroupBoundaryRequest, @@ -16,6 +16,11 @@ import { showMutationErrorToast } from "@/lib/mutation-errors"; import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder"; import { BbHttpError } from "@/lib/sdk"; import type { InlineQueuedMessageEditState } from "./useInlineQueuedMessageEditing"; +import { + isLocalQueuedMessage, + type QueuedMessageRow, +} from "@/lib/queued-message-rows"; +import { deleteSubmission, editSubmission } from "@/lib/message-delivery/store"; type QueuedMessageSendGuard = "current-head" | "exists" | "none"; @@ -27,7 +32,7 @@ interface SendQueuedMessageByIdArgs { interface UseQueuedMessageActionsArgs { threadId: string; - queuedMessages: readonly ThreadQueuedMessage[]; + queuedMessages: readonly QueuedMessageRow[]; sendProcessingPersistence: "clear-on-settle" | "until-left-queue"; onSendSuccess?: () => void; onSaveSuccess?: () => void; @@ -72,8 +77,12 @@ export function useQueuedMessageActions({ action: QueuedMessageProcessingAction; id: string; } | null>(null); - const queuedMessagesRef = useRef([]); + const queuedMessagesRef = useRef([]); queuedMessagesRef.current = queuedMessages; + const [localAction, setLocalAction] = useState<"edit" | "delete" | null>( + null, + ); + const localActionPendingRef = useRef(false); const displayedProcessingQueuedMessage = useMemo( () => @@ -90,10 +99,10 @@ export function useQueuedMessageActions({ const sendQueuedMessageById = useCallback( async ({ guard, messageId, mode }: SendQueuedMessageByIdArgs) => { - if ( - guard !== "none" && - !queuedMessagesRef.current.some((message) => message.id === messageId) - ) { + const message = queuedMessagesRef.current.find( + (row) => row.id === messageId, + ); + if (!message || isLocalQueuedMessage(message)) { return; } if ( @@ -137,29 +146,54 @@ export function useQueuedMessageActions({ if ( !inlineEditingQueuedMessage || activeComposerDraftInput.length === 0 || - updateQueuedMessage.isPending + updateQueuedMessage.isPending || + localActionPendingRef.current ) { return; } if ( inlineEditingQueuedMessage.ownerThreadId !== threadId || - !queuedMessagesRef.current.some( - (message) => message.id === inlineEditingQueuedMessage.queuedMessageId, - ) + (!inlineEditingQueuedMessage.localEditClaim && + !queuedMessagesRef.current.some( + (message) => + message.id === inlineEditingQueuedMessage.queuedMessageId, + )) ) { dismissInlineQueuedMessageEditor(); return; } const { expectedUpdatedAt, ownerThreadId, queuedMessageId } = inlineEditingQueuedMessage; + const message = queuedMessagesRef.current.find( + (row) => row.id === queuedMessageId, + ); + const local = + inlineEditingQueuedMessage.localEditClaim !== undefined || + (message !== undefined && isLocalQueuedMessage(message)); + if (local && !inlineEditingQueuedMessage.localEditClaim) return; + if (!local && (!message || !message.editable)) return; + if (local) { + localActionPendingRef.current = true; + setLocalAction("edit"); + } setProcessingQueuedMessage({ id: queuedMessageId, action: "edit" }); try { - await updateQueuedMessage.mutateAsync({ - expectedUpdatedAt, - id: ownerThreadId, - input: activeComposerDraftInput, - queuedMessageId, - }); + if (local) { + await editSubmission({ + threadId: ownerThreadId, + id: queuedMessageId, + expectedUpdatedAt, + input: activeComposerDraftInput, + editToken: inlineEditingQueuedMessage.localEditClaim?.token, + }); + } else { + await updateQueuedMessage.mutateAsync({ + expectedUpdatedAt, + id: ownerThreadId, + input: activeComposerDraftInput, + queuedMessageId, + }); + } onSaveSuccess?.(); dismissInlineQueuedMessageEditor(); } catch (error) { @@ -172,6 +206,10 @@ export function useQueuedMessageActions({ lifecycleOperation: "update_queued_message", }); } finally { + if (local) { + localActionPendingRef.current = false; + setLocalAction(null); + } setProcessingQueuedMessage((current) => current?.id === queuedMessageId ? null : current, ); @@ -187,12 +225,25 @@ export function useQueuedMessageActions({ const handleDeleteQueuedMessage = useCallback( (queuedMessageId: string) => { + const message = queuedMessagesRef.current.find( + (row) => row.id === queuedMessageId, + ); + if (!message || localActionPendingRef.current) return; + const local = isLocalQueuedMessage(message); + if (local && !message.editable) return; + if (local) { + localActionPendingRef.current = true; + setLocalAction("delete"); + } setProcessingQueuedMessage({ id: queuedMessageId, action: "delete" }); - void deleteQueuedMessage - .mutateAsync({ - id: threadId, - queuedMessageId, - }) + const deletion = local + ? deleteSubmission({ + threadId, + id: queuedMessageId, + expectedUpdatedAt: message.updatedAt, + }) + : deleteQueuedMessage.mutateAsync({ id: threadId, queuedMessageId }); + void deletion .catch((error) => { showMutationErrorToast({ error, @@ -201,6 +252,10 @@ export function useQueuedMessageActions({ }); }) .finally(() => { + if (local) { + localActionPendingRef.current = false; + setLocalAction(null); + } setProcessingQueuedMessage((current) => current?.id === queuedMessageId ? null : current, ); @@ -211,6 +266,20 @@ export function useQueuedMessageActions({ const handleReorderQueuedMessage = useCallback( (request: QueuedMessageReorderRequest) => { + if (queuedMessagesRef.current.some(isLocalQueuedMessage)) return; + const ids = [ + request.queuedMessageId, + request.previousQueuedMessageId, + request.nextQueuedMessageId, + request.groupBoundaryQueuedMessageId, + ].filter((id) => id != null); + if ( + ids.some( + (id) => + !queuedMessagesRef.current.some((message) => message.id === id), + ) + ) + return; void reorderQueuedMessage .mutateAsync({ ...request, @@ -229,6 +298,18 @@ export function useQueuedMessageActions({ const handleSetQueuedMessageGroupBoundary = useCallback( (request: QueuedMessageGroupBoundaryRequest) => { + if (queuedMessagesRef.current.some(isLocalQueuedMessage)) return; + const ids = [ + request.groupBoundaryQueuedMessageId, + ...request.expectedGroupedPrefixQueuedMessageIds, + ]; + if ( + ids.some( + (id) => + !queuedMessagesRef.current.some((message) => message.id === id), + ) + ) + return; void setQueuedMessageGroupBoundary .mutateAsync({ id: threadId, @@ -246,6 +327,7 @@ export function useQueuedMessageActions({ ); const queuedMessageActionPending = + localAction !== null || deleteQueuedMessage.isPending || reorderQueuedMessage.isPending || setQueuedMessageGroupBoundary.isPending || @@ -255,7 +337,8 @@ export function useQueuedMessageActions({ return { processingQueuedMessage: displayedProcessingQueuedMessage, queuedMessageActionPending, - isUpdateQueuedMessagePending: updateQueuedMessage.isPending, + isUpdateQueuedMessagePending: + updateQueuedMessage.isPending || localAction === "edit", sendQueuedMessageById, handleSaveInlineQueuedMessage, handleDeleteQueuedMessage, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 61a1584aa9b..5eaf1464d60 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -112,6 +112,60 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { + it.each(["send", "queue"] as const)( + "does not resurrect consumed rows when a durable %s receipt is replayed", + (kind) => { + const queryClient = createAppQueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + showMutationErrorToasts: false, + }); + queryClient.setQueryData(threadQueuedMessagesQueryKey("thread-1"), []); + queryClient.setQueryData(threadPromptHistoryQueryKey("thread-1"), []); + const queuedMessage = makeQueuedMessage({ + id: "consumed-row", + clientSubmissionId: "submission-1", + replayed: true, + }); + if (kind === "queue") { + applyQueuedMessageCreateResult({ + queryClient, + threadId: "thread-1", + queuedMessage, + transaction: undefined, + }); + } else { + applySendThreadMessageSuccess({ + queryClient, + realtimeConnected: false, + request: { + id: "thread-1", + input: queuedMessage.content, + mode: "queue-if-active", + clientSubmissionId: "submission-1", + }, + result: { + ok: true, + delivery: "queued", + queuedMessage, + clientSubmissionId: "submission-1", + replayed: true, + }, + transaction: undefined, + }); + } + expect( + queryClient.getQueryData(threadQueuedMessagesQueryKey("thread-1")), + ).toEqual([]); + expect( + queryClient.getQueryData(threadPromptHistoryQueryKey("thread-1")), + ).toEqual([]); + expect( + queryClient.getQueryState(threadQueuedMessagesQueryKey("thread-1")) + ?.isInvalidated, + ).toBe(true); + }, + ); + it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 6f73a86ca6c..a462ddb5a7d 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -1006,6 +1006,14 @@ export function applySendThreadMessageSuccess({ result, transaction, }: ApplySendThreadMessageSuccessArgs): void { + if (result.clientSubmissionId !== undefined) { + invalidateThreadAcceptedMessageQueriesWithoutRealtime({ + queryClient, + threadId: request.id, + }); + invalidateThreadQueueQueries({ queryClient, threadId: request.id }); + return; + } const optimisticCreatedAt = transaction?.optimisticCreatedAt ?? Date.now(); if (result.delivery === "queued") { const optimisticTimelineRowRemoved = @@ -1127,6 +1135,10 @@ export function applyQueuedMessageCreateResult({ threadId, transaction, }: QueuedMessageSuccessArgs): void { + if (queuedMessage.clientSubmissionId !== undefined) { + invalidateThreadQueueQueries({ queryClient, threadId }); + return; + } queryClient.setQueryData( threadQueuedMessagesQueryKey(threadId), (currentQueuedMessages) => { diff --git a/apps/app/src/hooks/useDurableMessageSubmission.test.tsx b/apps/app/src/hooks/useDurableMessageSubmission.test.tsx new file mode 100644 index 00000000000..e5c1a6f598a --- /dev/null +++ b/apps/app/src/hooks/useDurableMessageSubmission.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { emptyPromptDraftState } from "@bb/client-core"; +import { useDurableMessageSubmission } from "./useDurableMessageSubmission"; + +const mocks = vi.hoisted(() => ({ + enabled: true, + connected: true, + enqueue: vi.fn(), +})); + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { featureFlags: { durableMessageDelivery: mocks.enabled } }, + }), +})); +vi.mock("@/hooks/useServerConnectionState", () => ({ + useServerConnectionState: () => + mocks.connected ? "connected" : "disconnected", +})); +vi.mock("@/lib/message-delivery/store", () => ({ + enqueueSubmission: mocks.enqueue, +})); + +const input = [ + { type: "text" as const, text: "Keep this message", mentions: [] }, +]; +const draft = { storageKey: "draft-thread-1", value: emptyPromptDraftState() }; + +describe("durable composer submission", () => { + beforeEach(() => { + mocks.enabled = true; + mocks.connected = true; + mocks.enqueue.mockReset(); + }); + afterEach(cleanup); + + it("unlocks after local save while programmatic acceptance is still pending", async () => { + let accept = () => {}; + const acceptance = new Promise((resolve) => { + accept = resolve; + }); + mocks.enqueue.mockResolvedValue({ acceptance }); + const { result } = renderHook(() => + useDurableMessageSubmission("thread-1"), + ); + await act(async () => { + const saved = await result.current.enqueue( + { kind: "send", request: { input, mode: "queue-if-active" } }, + draft, + true, + ); + expect(saved.acceptance).toBe(acceptance); + }); + expect(result.current.isSaving).toBe(false); + expect(mocks.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "thread-1", + draft, + awaitAcceptance: true, + }), + ); + accept(); + await acceptance; + }); + + it("retains the composer after local persistence fails and permits another attempt", async () => { + mocks.enqueue.mockRejectedValueOnce(new Error("Storage is full")); + mocks.enqueue.mockResolvedValueOnce({}); + const { result } = renderHook(() => + useDurableMessageSubmission("thread-1"), + ); + await act(async () => { + await expect( + result.current.enqueue({ kind: "queue", request: { input } }, draft), + ).rejects.toThrow("Storage is full"); + }); + expect(result.current.isSaving).toBe(false); + await act(async () => { + await result.current.enqueue( + { kind: "queue", request: { input } }, + draft, + ); + }); + expect(mocks.enqueue).toHaveBeenCalledTimes(2); + }); + + it("does not persist steering or runtime commands", async () => { + const { result } = renderHook(() => + useDurableMessageSubmission("thread-1"), + ); + await act(async () => { + await expect( + result.current.enqueue( + { kind: "send", request: { input, mode: "steer" } }, + draft, + ), + ).rejects.toThrow("Steering requires"); + await expect( + result.current.enqueue( + { + kind: "queue", + request: { + input: [{ type: "text", text: "/clear", mentions: [] }], + }, + }, + draft, + ), + ).rejects.toThrow("does not support offline delivery"); + }); + expect(mocks.enqueue).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/hooks/useDurableMessageSubmission.ts b/apps/app/src/hooks/useDurableMessageSubmission.ts new file mode 100644 index 00000000000..9da597d1507 --- /dev/null +++ b/apps/app/src/hooks/useDurableMessageSubmission.ts @@ -0,0 +1,83 @@ +import { useCallback, useRef, useState } from "react"; +import { + isStandaloneBuiltinClearCommand, + isStandaloneBuiltinCompactCommand, + type PromptInput, +} from "@bb/domain"; +import type { PromptDraftState } from "@bb/client-core"; +import type { + CreateQueuedMessageRequest, + SendMessageRequest, +} from "@bb/server-contract"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useServerConnectionState } from "@/hooks/useServerConnectionState"; +import { enqueueSubmission } from "@/lib/message-delivery/store"; +import { notifyComposerSubmitted } from "@/lib/composer-submissions"; + +type DurableRequest = + | { kind: "send"; request: SendMessageRequest } + | { kind: "queue"; request: CreateQueuedMessageRequest }; + +export function isDurableMessageInput(input: readonly PromptInput[]): boolean { + return ( + !isStandaloneBuiltinClearCommand(input) && + !isStandaloneBuiltinCompactCommand(input) + ); +} + +export function useDurableMessageSubmission(threadId: string) { + const { data: config } = useSystemConfig(); + const connection = useServerConnectionState(); + const [isSaving, setIsSaving] = useState(false); + const savingRef = useRef(false); + const enabled = config?.featureFlags.durableMessageDelivery === true; + const connected = connection === "connected"; + + const enqueue = useCallback( + async ( + submission: DurableRequest, + draft: { storageKey: string; value: PromptDraftState }, + awaitAcceptance = false, + ) => { + if (savingRef.current) { + throw new Error("A message is still being saved."); + } + if (!enabled || !isDurableMessageInput(submission.request.input)) { + throw new Error("This message does not support offline delivery."); + } + savingRef.current = true; + setIsSaving(true); + try { + if (submission.kind === "send") { + const mode = submission.request.mode; + if (mode !== "queue-if-active" && mode !== "start") { + throw new Error("Steering requires a server connection."); + } + const saved = await enqueueSubmission({ + kind: "send", + request: { ...submission.request, mode }, + threadId, + draft, + awaitAcceptance, + }); + notifyComposerSubmitted({ kind: "thread", threadId }); + return saved; + } + const saved = await enqueueSubmission({ + ...submission, + threadId, + draft, + awaitAcceptance, + }); + notifyComposerSubmitted({ kind: "thread", threadId }); + return saved; + } finally { + savingRef.current = false; + setIsSaving(false); + } + }, + [enabled, threadId], + ); + + return { enabled, connected, isSaving, enqueue }; +} diff --git a/apps/app/src/hooks/usePromptDraftStorage.test.tsx b/apps/app/src/hooks/usePromptDraftStorage.test.tsx index cfcaac5d5e1..254e4a90ede 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.test.tsx +++ b/apps/app/src/hooks/usePromptDraftStorage.test.tsx @@ -4,6 +4,8 @@ import { act, cleanup, render, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPromptDraftAccessor, + preparePromptDraftSubmission, + completePromptDraftSubmission, usePromptDraftInputThreadIds, usePromptDraftStorage, } from "./usePromptDraftStorage"; @@ -383,3 +385,124 @@ describe("usePromptDraftStorage addQuote", () => { expect(second.result.current.text).toBe("> shared selection\n"); }); }); + +describe("prompt submission handoff", () => { + it("persists a compatible marked draft and cancels stale deferred writes before clearing", () => { + vi.useFakeTimers(); + const scope = uniqueScope(); + const { result } = renderHook(() => usePromptDraftStorage(scope)); + act(() => result.current.setTextAndMentions("Saved submission", [])); + const draft = result.current.getCurrent(); + const key = result.current.storageKey; + act(() => preparePromptDraftSubmission(key, draft, "submission-one")); + expect(JSON.parse(localStorage.getItem(key) ?? "null")).toMatchObject({ + text: draft.text, + mentions: [], + attachments: [], + submissionHandoffId: "submission-one", + }); + expect(result.current.getCurrent()).toEqual(draft); + act(() => + expect(completePromptDraftSubmission(key, draft, "submission-one")).toBe( + "cleared", + ), + ); + act(() => vi.advanceTimersByTime(500)); + expect(localStorage.getItem(key)).toBeNull(); + expect(result.current.text).toBe(""); + }); + + it("preserves a newer identical draft because an edit removes its handoff identity", () => { + vi.useFakeTimers(); + const scope = uniqueScope(); + const { result } = renderHook(() => usePromptDraftStorage(scope)); + act(() => result.current.setTextAndMentions("Repeated text", [])); + const draft = result.current.getCurrent(); + const key = result.current.storageKey; + act(() => preparePromptDraftSubmission(key, draft, "original-submission")); + act(() => result.current.setTextAndMentions("Different text", [])); + act(() => result.current.setTextAndMentions("Repeated text", [])); + expect( + completePromptDraftSubmission(key, draft, "original-submission"), + ).toBe("preserved"); + act(() => vi.advanceTimersByTime(500)); + expect(result.current.text).toBe("Repeated text"); + expect(JSON.parse(localStorage.getItem(key) ?? "null")).not.toHaveProperty( + "submissionHandoffId", + ); + }); + + it("throws on marker persistence failure while keeping the full current draft", () => { + const accessor = getPromptDraftAccessor(uniqueScope()); + const draft = { + text: "Keep my draft", + mentions: [], + attachments: [ + { + type: "localFile" as const, + path: "notes.txt", + name: "notes.txt", + sizeBytes: 12, + }, + ], + }; + accessor.setDraft(draft); + const save = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + expect(() => + preparePromptDraftSubmission( + accessor.storageKey, + draft, + "unsaved-submission", + ), + ).toThrow("Quota exceeded"); + expect(accessor.getCurrent()).toEqual(draft); + save.mockRestore(); + }); + + it("keeps an interrupted marked draft until its exact committed handoff is recovered", () => { + const accessor = getPromptDraftAccessor(uniqueScope()); + const draft = { text: "Crash recovery", mentions: [], attachments: [] }; + accessor.setDraft(draft); + preparePromptDraftSubmission( + accessor.storageKey, + draft, + "saved-submission", + ); + window.dispatchEvent( + new StorageEvent("storage", { key: accessor.storageKey }), + ); + expect(accessor.getCurrent()).toEqual(draft); + expect( + completePromptDraftSubmission( + accessor.storageKey, + draft, + "different-submission", + ), + ).toBe("preserved"); + const remove = vi + .spyOn(Storage.prototype, "removeItem") + .mockImplementation(() => { + throw new Error("Storage unavailable"); + }); + expect(() => + completePromptDraftSubmission( + accessor.storageKey, + draft, + "saved-submission", + ), + ).toThrow("Storage unavailable"); + expect(accessor.getCurrent()).toEqual(draft); + remove.mockRestore(); + expect( + completePromptDraftSubmission( + accessor.storageKey, + draft, + "saved-submission", + ), + ).toBe("cleared"); + }); +}); diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index 4662f8e1b9f..c54d0500b4f 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -23,6 +23,7 @@ export type PromptDraftScope = interface PromptDraftCacheEntry { rawValue: string | null; draft: PromptDraftState; + submissionHandoffId?: string; } type PromptDraftListener = () => void; @@ -61,6 +62,7 @@ function readPromptDraft(storageKey: string | null): PromptDraftState { promptDraftCache.set(storageKey, { rawValue, draft, + submissionHandoffId: readSubmissionHandoffId(rawValue), }); return draft; } @@ -94,7 +96,10 @@ function persistPromptDraftCache(storageKey: string): void { return; } - const serialized = serializePromptDraftStorage(cachedEntry.draft); + const serialized = serializePromptDraftEnvelope( + cachedEntry.draft, + cachedEntry.submissionHandoffId, + ); cachedEntry.rawValue = serialized; if (serialized === null) { window.localStorage.removeItem(storageKey); @@ -192,11 +197,15 @@ function writePromptDraft( ): void { if (!storageKey || typeof window === "undefined") return; + const hadHandoff = + promptDraftCache.get(storageKey)?.submissionHandoffId !== undefined || + readSubmissionHandoffId(readStoredPromptDraftValue(storageKey)) !== + undefined; promptDraftCache.set(storageKey, { rawValue: null, draft: isPromptDraftEmpty(value) ? EMPTY_PROMPT_DRAFT : value, }); - if (options.persist === "deferred") { + if (options.persist === "deferred" && !hadHandoff) { schedulePromptDraftPersist(storageKey); } else { persistPromptDraftCache(storageKey); @@ -204,6 +213,91 @@ function writePromptDraft( emitPromptDraftChange(storageKey); } +function readSubmissionHandoffId(rawValue: string | null): string | undefined { + if (rawValue === null) return undefined; + try { + const value: unknown = JSON.parse(rawValue); + if ( + typeof value === "object" && + value !== null && + "submissionHandoffId" in value && + typeof value.submissionHandoffId === "string" + ) + return value.submissionHandoffId; + } catch { + return undefined; + } + return undefined; +} + +function serializePromptDraftEnvelope( + draft: PromptDraftState, + submissionHandoffId?: string, +): string | null { + if (submissionHandoffId === undefined) + return serializePromptDraftStorage(draft); + return JSON.stringify({ + text: draft.text, + mentions: draft.mentions, + attachments: draft.attachments, + submissionHandoffId, + }); +} + +export function preparePromptDraftSubmission( + storageKey: string, + draft: PromptDraftState, + submissionHandoffId: string, +): void { + if (!arePromptDraftStatesEqual(readPromptDraft(storageKey), draft)) + throw new Error("The draft changed before it could be saved."); + const serialized = serializePromptDraftEnvelope(draft, submissionHandoffId); + if (serialized === null) + throw new Error("An empty draft cannot be submitted."); + clearPromptDraftPersistTimer(storageKey); + pendingPromptDraftStorageKeys.add(storageKey); + window.localStorage.setItem(storageKey, serialized); + if (window.localStorage.getItem(storageKey) !== serialized) + throw new Error( + "The draft could not be saved. Your message has not been sent.", + ); + promptDraftCache.set(storageKey, { + draft, + rawValue: serialized, + submissionHandoffId, + }); + pendingPromptDraftStorageKeys.delete(storageKey); + emitPromptDraftChange(storageKey); +} + +export function completePromptDraftSubmission( + storageKey: string, + draft: PromptDraftState, + submissionHandoffId: string, +): "cleared" | "preserved" { + const currentDraft = readPromptDraft(storageKey); + const rawValue = window.localStorage.getItem(storageKey); + if ( + promptDraftCache.get(storageKey)?.submissionHandoffId !== + submissionHandoffId || + readSubmissionHandoffId(rawValue) !== submissionHandoffId || + !arePromptDraftStatesEqual(currentDraft, draft) || + !arePromptDraftStatesEqual(parsePromptDraftStorage(rawValue), draft) + ) + return "preserved"; + clearPromptDraftPersistTimer(storageKey); + window.localStorage.removeItem(storageKey); + if (window.localStorage.getItem(storageKey) !== null) + throw new Error("The saved draft could not be cleared."); + pendingPromptDraftStorageKeys.delete(storageKey); + promptDraftCache.set(storageKey, { + draft: EMPTY_PROMPT_DRAFT, + rawValue: null, + }); + emitPromptDraftChange(storageKey); + return "cleared"; +} + function restorePromptDraftIfEmpty( storageKey: string | null, value: PromptDraftState, @@ -552,3 +646,10 @@ export function usePromptDraftInputThreadIds( return threadIds; }, [presenceSnapshot, subscriptions]); } + +export function getPromptDraftSubmissionHandoff( + storageKey: string, +): string | undefined { + readPromptDraft(storageKey); + return promptDraftCache.get(storageKey)?.submissionHandoffId; +} diff --git a/apps/app/src/hooks/useQueuedMessageRows.ts b/apps/app/src/hooks/useQueuedMessageRows.ts new file mode 100644 index 00000000000..ef3ca3602bd --- /dev/null +++ b/apps/app/src/hooks/useQueuedMessageRows.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import type { ThreadQueuedMessage } from "@bb/domain"; +import { useThreadMessageSubmissions } from "@/lib/message-delivery/useThreadMessageSubmissions"; +import { projectQueuedMessageRows } from "@/lib/queued-message-rows"; +import { useServerConnectionState } from "@/hooks/useServerConnectionState"; + +export function useQueuedMessageRows( + threadId: string, + serverMessages: readonly ThreadQueuedMessage[], +) { + const submissions = useThreadMessageSubmissions(threadId); + const connected = useServerConnectionState() === "connected"; + return useMemo( + () => projectQueuedMessageRows({ serverMessages, submissions, connected }), + [serverMessages, submissions, connected], + ); +} diff --git a/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx b/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx new file mode 100644 index 00000000000..cd1c9c597ae --- /dev/null +++ b/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { appToast } from "@/components/ui/app-toast"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { + systemConfigQueryKey, + threadPromptHistoryQueryKey, + threadQueuedMessagesQueryKey, + threadTimelineQueryKeyPrefix, +} from "@/hooks/queries/query-keys"; +import { + applyQueuedMessageCreateResult, + applySendThreadMessageSuccess, +} from "@/hooks/cache-owners/thread-runtime-cache-owner"; +import { BbHttpError, sdk } from "@/lib/sdk"; +import { wsManager } from "@/lib/ws"; +import { startMessageDelivery } from "./coordinator"; + +const submissionRejectionSchema = z.object({ + details: z.object({ + submission: z.object({ + clientSubmissionId: z.string(), + acceptance: z.literal("rejected"), + }), + }), +}); + +export function MessageDeliverySync() { + const queryClient = useQueryClient(); + const { data: config } = useSystemConfig(); + const enabled = config?.featureFlags.durableMessageDelivery === true; + const errorRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + let deliverySupported = true; + const requireConfirmation = ( + expected: string, + received: string | undefined, + ) => { + if (expected === received) return; + deliverySupported = false; + void queryClient.invalidateQueries({ queryKey: systemConfigQueryKey() }); + const error = new Error( + "Message delivery paused: the server did not confirm the saved submission ID.", + ); + appToast.error(error.message); + throw error; + }; + return startMessageDelivery({ + getAvailability: () => ({ + enabled: deliverySupported, + connected: wsManager.getConnectionState() === "connected", + }), + subscribeAvailability: (listener) => + wsManager.onConnectionStateChange(listener), + deliver: async (entry, signal) => { + if (entry.kind === "send") { + const result = await sdk.threads.send({ + ...entry.request, + threadId: entry.threadId, + signal, + }); + requireConfirmation( + entry.clientSubmissionId, + result.clientSubmissionId, + ); + return { kind: "send", result }; + } + const result = await sdk.threads.queuedMessages.create({ + ...entry.request, + threadId: entry.threadId, + signal, + }); + requireConfirmation( + entry.clientSubmissionId, + result.clientSubmissionId, + ); + return { kind: "queue", result }; + }, + reconcile: async (entry, accepted, signal) => { + if (entry.kind === "send" && accepted.kind === "send") { + applySendThreadMessageSuccess({ + queryClient, + realtimeConnected: false, + request: { ...entry.request, id: entry.threadId }, + result: accepted.result, + transaction: undefined, + }); + } else if (accepted.kind === "queue") { + applyQueuedMessageCreateResult({ + queryClient, + threadId: entry.threadId, + queuedMessage: accepted.result, + transaction: undefined, + }); + } + await queryClient.fetchQuery({ + queryKey: threadQueuedMessagesQueryKey(entry.threadId), + queryFn: () => + sdk.threads.queuedMessages.list({ + threadId: entry.threadId, + signal, + }), + staleTime: 0, + }); + await Promise.all([ + queryClient.refetchQueries( + { + queryKey: threadTimelineQueryKeyPrefix(entry.threadId), + type: "active", + }, + { throwOnError: true, cancelRefetch: false }, + ), + queryClient.refetchQueries( + { + queryKey: threadPromptHistoryQueryKey(entry.threadId), + type: "active", + }, + { throwOnError: true, cancelRefetch: false }, + ), + ]); + }, + classifyFailure: (error, entry) => { + const message = + error instanceof Error ? error.message : "Message delivery failed."; + if (!(error instanceof BbHttpError)) + return { kind: "unknown", message }; + if ( + error.status === 408 || + error.status === 429 || + error.status >= 500 || + error.code === "queued_message_claim_lost" || + error.code === "queued_message_auto_send_paused" || + error.code === "awaiting_user_interaction" || + (error.status === 409 && error.code !== "client_submission_conflict") + ) + return { kind: "transient", message }; + const rejection = submissionRejectionSchema.safeParse(error.body); + return { + kind: "rejected", + message, + resolvesUncertainty: + rejection.success && + rejection.data.details.submission.clientSubmissionId === + entry.clientSubmissionId, + }; + }, + onError: (error) => { + if (errorRef.current === error.message) return; + errorRef.current = error.message; + appToast.error(error.message); + }, + }); + }, [enabled, queryClient]); + + return null; +} diff --git a/apps/app/src/lib/message-delivery/coordinator.test.ts b/apps/app/src/lib/message-delivery/coordinator.test.ts new file mode 100644 index 00000000000..5713208d7d7 --- /dev/null +++ b/apps/app/src/lib/message-delivery/coordinator.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { MessageDeliveryOptions } from "./coordinator"; + +let store: typeof import("./store"); +let coordinator: typeof import("./coordinator"); +let stop: (() => void) | undefined; +const accepted = { + kind: "send" as const, + result: { ok: true as const, delivery: "sent" as const }, +}; + +beforeEach(async () => { + vi.resetModules(); + localStorage.clear(); + vi.stubGlobal("indexedDB", new IDBFactory()); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + store = await import("./store"); + coordinator = await import("./coordinator"); + vi.spyOn(Math, "random").mockReturnValue(0.5); +}); + +afterEach(async () => { + stop?.(); + stop = undefined; + await store.refreshSubmissions(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function enqueue(text: string) { + const { getPromptDraftAccessor } = + await import("@/hooks/usePromptDraftStorage"); + const accessor = getPromptDraftAccessor({ + kind: "thread", + threadId: "thread-one", + projectId: "project-one", + }); + const draft = { text, mentions: [], attachments: [] }; + accessor.setDraft(draft); + return store.enqueueSubmission({ + threadId: "thread-one", + kind: "send", + request: { mode: "queue-if-active", input: [{ type: "text", text }] }, + draft: { storageKey: accessor.storageKey, value: draft }, + }); +} + +function options( + overrides: Partial = {}, +): MessageDeliveryOptions { + return { + getAvailability: () => ({ enabled: true, connected: true }), + subscribeAvailability: () => () => undefined, + deliver: vi.fn().mockResolvedValue(accepted), + reconcile: vi.fn().mockResolvedValue(undefined), + classifyFailure: (error) => ({ + kind: "unknown", + message: error instanceof Error ? error.message : "Disconnected", + }), + ...overrides, + }; +} + +it("retries authoritative reads after acceptance without sending again", async () => { + await enqueue("Accepted message"); + vi.useFakeTimers({ + toFake: [ + "Date", + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + ], + }); + const deliver = vi.fn().mockResolvedValue(accepted); + const reconcile = vi + .fn() + .mockRejectedValueOnce(new TypeError("Read disconnected")) + .mockResolvedValue(undefined); + stop = coordinator.startMessageDelivery(options({ deliver, reconcile })); + await vi.waitFor(() => + expect(store.getSubmissions()[0]).toMatchObject({ + status: "accepted", + error: "Read disconnected", + }), + ); + expect(deliver).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_100); + await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); + expect(deliver).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenCalledTimes(2); +}); + +it("aborts a hung request and retries its same ID before delivering later work", async () => { + const first = await enqueue("First message"); + const second = await enqueue("Second message"); + vi.useFakeTimers({ + toFake: [ + "Date", + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + ], + }); + const deliver = vi + .fn() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValue(accepted); + stop = coordinator.startMessageDelivery(options({ deliver })); + await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)); + const signal = deliver.mock.calls[0]?.[1]; + await vi.advanceTimersByTimeAsync(30_000); + await vi.waitFor(() => + expect(store.getSubmissions()[0]?.error).toBe("Message delivery timed out"), + ); + expect(signal?.aborted).toBe(true); + await vi.advanceTimersByTimeAsync(1_100); + await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); + expect(deliver.mock.calls.map(([entry]) => entry.clientSubmissionId)).toEqual( + [ + first.clientSubmissionId, + first.clientSubmissionId, + second.clientSubmissionId, + ], + ); +}); + +it("keeps uncertain prior attempts when a later error cannot establish rejection", async () => { + const first = await enqueue("Uncertain message"); + await enqueue("Must wait"); + const initial = await store.claimSubmission( + first.threadId, + "old-tab", + Date.now(), + 45_000, + ); + if (!("claim" in initial)) throw new Error("Expected a claim"); + await store.settleSubmission(initial.claim, { + kind: "retry", + error: "Response lost", + nextAttemptAt: 0, + }); + const deliver = vi.fn().mockRejectedValue(new Error("Thread archived")); + stop = coordinator.startMessageDelivery( + options({ + deliver, + classifyFailure: () => ({ kind: "rejected", message: "Thread archived" }), + }), + ); + await vi.waitFor(() => + expect(store.getSubmissions()[0]).toMatchObject({ + id: first.id, + status: "uncertain", + error: "Thread archived", + }), + ); + expect(deliver).toHaveBeenCalledTimes(1); + expect(store.getSubmissions()[1]?.attempted).toBe(false); +}); + +it("retains terminal rejection while allowing the next message to proceed", async () => { + const first = await enqueue("Rejected message"); + const second = await enqueue("Allowed message"); + const deliver = vi + .fn() + .mockRejectedValueOnce(new Error("Invalid model")) + .mockResolvedValue(accepted); + stop = coordinator.startMessageDelivery( + options({ + deliver, + classifyFailure: () => ({ kind: "rejected", message: "Invalid model" }), + }), + ); + await vi.waitFor(() => + expect(store.getSubmissions()).toEqual([ + expect.objectContaining({ id: first.id, status: "rejected" }), + ]), + ); + expect(deliver.mock.calls.map(([entry]) => entry.id)).toEqual([ + first.id, + second.id, + ]); +}); + +it("pauses saved submissions until both receipt support and connection return", async () => { + await enqueue("Saved while offline"); + let enabled = false; + let connected = true; + let changed: () => void = () => undefined; + const deliver = vi.fn().mockResolvedValue(accepted); + stop = coordinator.startMessageDelivery( + options({ + deliver, + getAvailability: () => ({ enabled, connected }), + subscribeAvailability: (listener) => { + changed = listener; + return () => undefined; + }, + }), + ); + await store.refreshSubmissions(); + expect(deliver).not.toHaveBeenCalled(); + enabled = true; + connected = false; + changed(); + await store.refreshSubmissions(); + expect(deliver).not.toHaveBeenCalled(); + connected = true; + changed(); + await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); + expect(deliver).toHaveBeenCalledTimes(1); +}); diff --git a/apps/app/src/lib/message-delivery/coordinator.ts b/apps/app/src/lib/message-delivery/coordinator.ts new file mode 100644 index 00000000000..ceb5aed9764 --- /dev/null +++ b/apps/app/src/lib/message-delivery/coordinator.ts @@ -0,0 +1,302 @@ +import { nanoid } from "nanoid"; +import { + claimSubmission, + completeSubmissionHandoff, + getSubmissionStorageError, + getSubmissions, + pruneObservedSubmissions, + refreshSubmissions, + renewSubmissionClaim, + settleSubmission, + subscribeSubmissions, + type Submission, + type SubmissionAcceptance, + type SubmissionClaim, +} from "./store"; + +export interface DeliveryFailure { + kind: "transient" | "rejected" | "unknown"; + message: string; + retryAfterMs?: number; + resolvesUncertainty?: boolean; +} + +export interface MessageDeliveryOptions { + getAvailability: () => { enabled: boolean; connected: boolean }; + subscribeAvailability: (listener: () => void) => () => void; + deliver: ( + entry: Submission, + signal: AbortSignal, + ) => Promise; + reconcile: ( + entry: Submission, + accepted: SubmissionAcceptance, + signal: AbortSignal, + ) => Promise; + classifyFailure: (error: unknown, entry: Submission) => DeliveryFailure; + onError?: (error: Error) => void; +} + +const leaseMs = 45_000; +const requestTimeoutMs = 30_000; +const renewIntervalMs = 10_000; +const maxConcurrentThreads = 2; + +function failureError(error: unknown): Error { + return error instanceof Error + ? error + : new Error("Message delivery could not be completed."); +} + +function retryDelay(entry: Submission, retryAfterMs = 0): number { + const backoff = Math.min(60_000, 1_000 * 2 ** Math.min(entry.retryCount, 6)); + return Math.max( + retryAfterMs, + Math.round(backoff * (0.8 + Math.random() * 0.4)), + ); +} + +export function startMessageDelivery( + options: MessageDeliveryOptions, +): () => void { + const owner = nanoid(); + const active = new Set(); + const wakeTimes = new Map(); + const controllers = new Set(); + const recovering = new Set(); + let stopped = false; + let scheduled = false; + let timer: ReturnType | undefined; + let lastStorageError: Error | null = null; + + const available = () => { + const state = options.getAvailability(); + return ( + !stopped && state.enabled && state.connected && navigator.onLine !== false + ); + }; + + function scheduleTimer(): void { + if (timer !== undefined) clearTimeout(timer); + const future = [...wakeTimes.values()].filter( + (time) => Number.isFinite(time) && time > Date.now(), + ); + if (future.length === 0 || stopped) { + timer = undefined; + return; + } + timer = setTimeout(kick, Math.max(0, Math.min(...future) - Date.now())); + } + + function kick(): void { + if (scheduled || stopped) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + pump(); + }); + } + + async function attempt(claim: SubmissionClaim): Promise { + const controller = new AbortController(); + controllers.add(controller); + let leaseLost = false; + let rejectAbort: (error: Error) => void = () => undefined; + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + const onAbort = () => + rejectAbort( + controller.signal.reason instanceof Error + ? controller.signal.reason + : new DOMException("Message delivery interrupted", "AbortError"), + ); + controller.signal.addEventListener("abort", onAbort, { once: true }); + const timeout = setTimeout( + () => + controller.abort( + new DOMException("Message delivery timed out", "TimeoutError"), + ), + requestTimeoutMs, + ); + const renewal = setInterval(() => { + void renewSubmissionClaim(claim, Date.now(), leaseMs) + .then((renewed) => { + if (!renewed) { + leaseLost = true; + controller.abort( + new DOMException("Message delivery claim expired", "AbortError"), + ); + } + }) + .catch((error) => { + leaseLost = true; + controller.abort(failureError(error)); + }); + }, renewIntervalMs); + try { + const entry = claim.entry; + if (entry.accepted !== null) { + await Promise.race([ + options.reconcile(entry, entry.accepted, controller.signal), + aborted, + ]); + if (!leaseLost) await settleSubmission(claim, { kind: "reconciled" }); + } else { + const accepted = await Promise.race([ + options.deliver(entry, controller.signal), + aborted, + ]); + if (!leaseLost) + await settleSubmission(claim, { kind: "accepted", accepted }); + } + } catch (error) { + if (leaseLost) { + wakeTimes.set(claim.threadId, Date.now() + leaseMs); + return; + } + const failure = options.classifyFailure(error, claim.entry); + const definitive = + claim.entry.accepted === null && + failure.kind === "rejected" && + (claim.entry.attempts === 1 || failure.resolvesUncertainty === true); + if (definitive) { + await settleSubmission(claim, { + kind: "rejected", + error: failure.message, + }); + options.onError?.(failureError(error)); + } else { + const nextAttemptAt = + Date.now() + retryDelay(claim.entry, failure.retryAfterMs); + await settleSubmission(claim, { + kind: "retry", + error: failure.message, + nextAttemptAt, + }); + wakeTimes.set(claim.threadId, nextAttemptAt); + } + } finally { + clearTimeout(timeout); + clearInterval(renewal); + controller.signal.removeEventListener("abort", onAbort); + controllers.delete(controller); + } + } + + async function drain(threadId: string): Promise { + try { + const result = await claimSubmission( + threadId, + owner, + Date.now(), + leaseMs, + ); + if ("claim" in result) { + if (!available()) { + await settleSubmission(result.claim, { + kind: "retry", + error: "Waiting for connection", + nextAttemptAt: Date.now() + 1_000, + unattempted: true, + }); + wakeTimes.set(threadId, Date.now() + 1_000); + return; + } + await attempt(result.claim); + } else if (result.wakeAt !== null) wakeTimes.set(threadId, result.wakeAt); + else wakeTimes.set(threadId, Infinity); + } catch (error) { + options.onError?.(failureError(error)); + wakeTimes.set(threadId, Date.now() + 5_000); + } finally { + active.delete(threadId); + await refreshSubmissions(); + kick(); + } + } + + function pump(): void { + if (stopped) return; + const error = getSubmissionStorageError(); + if (error && error !== lastStorageError) options.onError?.(error); + lastStorageError = error; + const entries = getSubmissions(); + for (const entry of entries) { + if ( + !entry.handoff.completed && + !recovering.has(entry.id) && + (wakeTimes.get(`handoff:${entry.id}`) ?? 0) <= Date.now() + ) { + recovering.add(entry.id); + void completeSubmissionHandoff(entry) + .catch((failure) => { + options.onError?.(failureError(failure)); + wakeTimes.set(`handoff:${entry.id}`, Date.now() + 5_000); + }) + .finally(() => { + recovering.delete(entry.id); + scheduleTimer(); + }); + } + } + if (!available() || error) { + scheduleTimer(); + return; + } + const threads = new Set( + entries + .filter( + (entry) => + entry.status !== "rejected" && + !(entry.status === "accepted" && entry.reconciled), + ) + .map((entry) => entry.threadId), + ); + for (const threadId of threads) { + if (active.size >= maxConcurrentThreads) break; + if (active.has(threadId) || (wakeTimes.get(threadId) ?? 0) > Date.now()) + continue; + active.add(threadId); + void drain(threadId); + } + scheduleTimer(); + } + + function wake(): void { + wakeTimes.clear(); + if (!available()) + for (const controller of controllers) + controller.abort( + new DOMException("Connection unavailable", "AbortError"), + ); + void refreshSubmissions().then(kick); + } + + const unsubscribeStore = subscribeSubmissions(() => { + for (const key of wakeTimes.keys()) + if (!key.startsWith("handoff:")) wakeTimes.delete(key); + kick(); + }); + const unsubscribeAvailability = options.subscribeAvailability(wake); + window.addEventListener("focus", wake); + window.addEventListener("online", wake); + window.addEventListener("offline", wake); + void pruneObservedSubmissions().catch((error) => + options.onError?.(failureError(error)), + ); + wake(); + return () => { + stopped = true; + if (timer !== undefined) clearTimeout(timer); + unsubscribeStore(); + unsubscribeAvailability(); + window.removeEventListener("focus", wake); + window.removeEventListener("online", wake); + window.removeEventListener("offline", wake); + for (const controller of controllers) + controller.abort( + new DOMException("Message delivery paused", "AbortError"), + ); + }; +} diff --git a/apps/app/src/lib/message-delivery/store.test.ts b/apps/app/src/lib/message-delivery/store.test.ts new file mode 100644 index 00000000000..e4f0b2df78d --- /dev/null +++ b/apps/app/src/lib/message-delivery/store.test.ts @@ -0,0 +1,319 @@ +// @vitest-environment jsdom + +import { IDBFactory, IDBKeyRange, IDBObjectStore } from "fake-indexeddb"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { SubmissionClaim } from "./store"; + +let store: typeof import("./store"); +let drafts: typeof import("@/hooks/usePromptDraftStorage"); +const acceptance = { + kind: "send" as const, + result: { ok: true as const, delivery: "sent" as const }, +}; + +beforeEach(async () => { + vi.resetModules(); + localStorage.clear(); + vi.stubGlobal("indexedDB", new IDBFactory()); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + store = await import("./store"); + drafts = await import("@/hooks/usePromptDraftStorage"); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function enqueue(text: string, awaitAcceptance = false) { + const accessor = drafts.getPromptDraftAccessor({ + kind: "thread", + threadId: "thread-one", + projectId: "project-one", + }); + const draft = { text, mentions: [], attachments: [] }; + accessor.setDraft(draft); + return store.enqueueSubmission({ + threadId: "thread-one", + kind: "send", + request: { mode: "queue-if-active", input: [{ type: "text", text }] }, + draft: { storageKey: accessor.storageKey, value: draft }, + awaitAcceptance, + }); +} + +async function claim( + owner: string, + now = Date.now(), + leaseMs = 45_000, +): Promise { + const result = await store.claimSubmission("thread-one", owner, now, leaseMs); + if (!("claim" in result)) throw new Error("Expected a submission claim"); + return result.claim; +} + +it("commits distinct identical submissions in order before clearing their drafts", async () => { + const first = await enqueue("Same message"); + const second = await enqueue("Same message"); + await store.refreshSubmissions(); + expect(first.clientSubmissionId).not.toBe(second.clientSubmissionId); + expect(second.sequence).toBe(first.sequence + 1); + expect( + store.getSubmissions().map((entry) => entry.handoff.completed), + ).toEqual([true, true]); + expect(localStorage.getItem(first.handoff.storageKey)).toBeNull(); +}); + +it("rolls back failed enqueue transactions without clearing the marked draft or consuming sequence", async () => { + const add = vi + .spyOn(IDBObjectStore.prototype, "add") + .mockImplementationOnce(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + await expect(enqueue("Keep this draft")).rejects.toThrow("Quota exceeded"); + await store.refreshSubmissions(); + expect(store.getSubmissions()).toEqual([]); + const accessor = drafts.getPromptDraftAccessor({ + kind: "thread", + threadId: "thread-one", + projectId: "project-one", + }); + expect(accessor.getCurrent().text).toBe("Keep this draft"); + add.mockRestore(); + expect((await enqueue("Try again")).sequence).toBe(0); +}); + +it("recovers a committed handoff after draft clearing failed, without losing a newer identical draft", async () => { + const remove = vi + .spyOn(Storage.prototype, "removeItem") + .mockImplementationOnce(() => { + throw new Error("Clear failed"); + }); + const entry = await enqueue("Repeated text"); + remove.mockRestore(); + await store.refreshSubmissions(); + expect(store.getSubmissions()[0]?.handoff.completed).toBe(false); + const accessor = drafts.getPromptDraftAccessor({ + kind: "thread", + threadId: "thread-one", + projectId: "project-one", + }); + accessor.setDraft({ text: "New draft", mentions: [], attachments: [] }); + accessor.setDraft({ text: "Repeated text", mentions: [], attachments: [] }); + await store.completeSubmissionHandoff(entry); + expect(accessor.getCurrent().text).toBe("Repeated text"); + await store.refreshSubmissions(); + expect(store.getSubmissions()[0]?.handoff.completed).toBe(true); +}); + +it("keeps an accepted record until its draft handoff also completes", async () => { + const remove = vi + .spyOn(Storage.prototype, "removeItem") + .mockImplementationOnce(() => { + throw new Error("Clear failed"); + }); + const entry = await enqueue("Accepted before cleanup"); + remove.mockRestore(); + await store.settleSubmission(await claim("owner"), { + kind: "accepted", + accepted: acceptance, + }); + await store.settleSubmission(await claim("owner"), { kind: "reconciled" }); + await store.refreshSubmissions(); + expect(store.getSubmissions()[0]).toMatchObject({ + status: "accepted", + reconciled: true, + handoff: { completed: false }, + }); + await store.completeSubmissionHandoff(entry); + await store.refreshSubmissions(); + expect(store.getSubmissions()).toEqual([]); +}); + +it("atomically chooses one tab and fences expired settlement while retrying the same head", async () => { + const first = await enqueue("First"); + await enqueue("Second"); + const now = Date.now(); + const results = await Promise.all([ + store.claimSubmission("thread-one", "tab-one", now, 10), + store.claimSubmission("thread-one", "tab-two", now, 10), + ]); + expect(results.filter((result) => "claim" in result)).toHaveLength(1); + const winner = results.find((result) => "claim" in result); + if (!winner || !("claim" in winner)) + throw new Error("Expected a winning claim"); + const replacement = await claim("replacement", now + 11, 100); + expect(replacement.entry.clientSubmissionId).toBe(first.clientSubmissionId); + expect( + await store.settleSubmission( + winner.claim, + { kind: "accepted", accepted: acceptance }, + now + 12, + ), + ).toBe(false); + expect( + await store.settleSubmission( + replacement, + { kind: "accepted", accepted: acceptance }, + now + 12, + ), + ).toBe(true); +}); + +it("skips retained rejection and corrects it with a new ID at the delivery tail", async () => { + const first = await enqueue("Rejected"); + const second = await enqueue("Later"); + await store.settleSubmission(await claim("owner"), { + kind: "rejected", + error: "Invalid model", + }); + const later = await claim("owner"); + expect(later.entry.id).toBe(second.id); + await store.settleSubmission(later, { + kind: "retry", + error: "Disconnected", + nextAttemptAt: Date.now() + 10_000, + }); + await store.refreshSubmissions(); + const rejected = store + .getSubmissions() + .find((entry) => entry.id === first.id); + if (!rejected) throw new Error("Rejected message was lost"); + await store.editSubmission({ + threadId: first.threadId, + id: first.id, + expectedUpdatedAt: rejected.updatedAt, + input: [{ type: "text", text: "Corrected" }], + }); + await store.refreshSubmissions(); + const corrected = store + .getSubmissions() + .find((entry) => entry.id === first.id); + expect(corrected?.clientSubmissionId).not.toBe(first.clientSubmissionId); + expect(corrected?.sequence).toBeGreaterThan(second.sequence); + expect(corrected?.createdAt).toBe(first.createdAt); +}); + +it("pauses delivery while an inline edit owns the claim and fences an expired editor", async () => { + const entry = await enqueue("Edit this"); + const edit = await store.acquireSubmissionEdit({ + threadId: entry.threadId, + id: entry.id, + expectedUpdatedAt: entry.updatedAt, + }); + expect( + await store.claimSubmission(entry.threadId, "sender", Date.now(), 45_000), + ).toEqual({ wakeAt: edit.expiresAt }); + await store.editSubmission({ + threadId: entry.threadId, + id: entry.id, + expectedUpdatedAt: entry.updatedAt, + input: [{ type: "text", text: "Updated" }], + editToken: edit.token, + }); + const delivering = await claim("sender"); + expect(delivering.entry.request.input).toEqual([ + { type: "text", text: "Updated" }, + ]); + await expect( + store.editSubmission({ + threadId: entry.threadId, + id: entry.id, + expectedUpdatedAt: entry.updatedAt, + input: [{ type: "text", text: "Stale edit" }], + editToken: edit.token, + }), + ).rejects.toThrow("already being delivered"); +}); + +it("resolves a programmatic caller after durable acceptance even when another reader completes cleanup", async () => { + const entry = await enqueue("Programmatic message", true); + const accepted = entry.acceptance; + expect(accepted).toBeDefined(); + await store.settleSubmission(await claim("other-tab"), { + kind: "accepted", + accepted: acceptance, + }); + await store.refreshSubmissions(); + await expect(accepted).resolves.toBeUndefined(); + await store.settleSubmission(await claim("other-tab"), { + kind: "reconciled", + }); + await store.refreshSubmissions(); + expect(store.getSubmissions()).toEqual([]); +}); + +it("reopens committed storage with the same transport identity", async () => { + const original = await enqueue("Keep across reload"); + vi.resetModules(); + const reloaded = await import("./store"); + await reloaded.refreshSubmissions(); + expect(reloaded.getSubmissions()[0]).toMatchObject({ + id: original.id, + clientSubmissionId: original.clientSubmissionId, + handoff: { completed: true }, + }); + const next = await reloaded.claimSubmission( + original.threadId, + "reloaded-tab", + Date.now(), + 45_000, + ); + expect(next).toMatchObject({ + claim: { entry: { clientSubmissionId: original.clientSubmissionId } }, + }); +}); + +it("fences an editor after expiry lets a delivery owner claim the saved payload", async () => { + const original = await enqueue("Original payload"); + const edit = await store.acquireSubmissionEdit({ + threadId: original.threadId, + id: original.id, + expectedUpdatedAt: original.updatedAt, + }); + const delivery = await store.claimSubmission( + original.threadId, + "replacement", + edit.expiresAt + 1, + 45_000, + ); + expect(delivery).toMatchObject({ + claim: { entry: { id: original.id, request: original.request } }, + }); + await expect( + store.editSubmission({ + threadId: original.threadId, + id: original.id, + expectedUpdatedAt: original.updatedAt, + input: [{ type: "text", text: "Unsaved new wording" }], + editToken: edit.token, + }), + ).rejects.toThrow("already being delivered"); +}); + +it("leaves a rejected original untouched when its correction transaction fails", async () => { + const original = await enqueue("Original rejected payload"); + await store.settleSubmission(await claim("owner"), { + kind: "rejected", + error: "Invalid model", + }); + await store.refreshSubmissions(); + const rejected = store.getSubmissions()[0]; + if (!rejected) throw new Error("Expected retained rejected message"); + const put = vi + .spyOn(IDBObjectStore.prototype, "put") + .mockImplementationOnce(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + await expect( + store.editSubmission({ + threadId: original.threadId, + id: original.id, + expectedUpdatedAt: rejected.updatedAt, + input: [{ type: "text", text: "Corrected wording" }], + }), + ).rejects.toThrow("Quota exceeded"); + put.mockRestore(); + await store.refreshSubmissions(); + expect(store.getSubmissions()[0]).toEqual(rejected); +}); diff --git a/apps/app/src/lib/message-delivery/store.ts b/apps/app/src/lib/message-delivery/store.ts new file mode 100644 index 00000000000..462bb303984 --- /dev/null +++ b/apps/app/src/lib/message-delivery/store.ts @@ -0,0 +1,810 @@ +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { promptTextMentionSchema, threadQueuedMessageSchema } from "@bb/domain"; +import { + createQueuedMessageRequestSchema, + sendMessageRequestSchema, + sendMessageResponseSchema, + uploadedPromptAttachmentSchema, +} from "@bb/server-contract"; +import { + completePromptDraftSubmission, + preparePromptDraftSubmission, + getPromptDraftSubmissionHandoff, +} from "@/hooks/usePromptDraftStorage"; + +const databaseName = "bb-message-delivery"; +const invalidationKey = "bb.message-delivery.changed"; +const acceptedSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("send"), result: sendMessageResponseSchema }), + z.object({ + kind: z.literal("queue"), + result: threadQueuedMessageSchema.extend({ + replayed: z.boolean().optional(), + }), + }), +]); +const draftSchema = z.object({ + text: z.string(), + mentions: z.array(promptTextMentionSchema), + attachments: z.array(uploadedPromptAttachmentSchema), +}); +const submissionFields = { + version: z.literal(1), + id: z.string().min(1), + clientSubmissionId: z.string().min(1).max(128), + threadId: z.string().min(1), + sequence: z.number().int().nonnegative(), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative(), + status: z.enum(["pending", "uncertain", "rejected", "accepted"]), + attempted: z.boolean(), + attempts: z.number().int().nonnegative(), + retryCount: z.number().int().nonnegative(), + retainAcceptanceUntil: z.number().nullable(), + acceptanceObserved: z.boolean(), + nextAttemptAt: z.number().nonnegative(), + error: z.string().nullable(), + accepted: acceptedSchema.nullable(), + reconciled: z.boolean(), + handoff: z.object({ + storageKey: z.string(), + draft: draftSchema, + handoffId: z.string(), + completed: z.boolean(), + }), +}; +export const submissionSchema = z + .discriminatedUnion("kind", [ + z.object({ + ...submissionFields, + kind: z.literal("send"), + request: sendMessageRequestSchema.extend({ + clientSubmissionId: z.string().min(1).max(128), + mode: z.enum(["start", "queue-if-active"]), + }), + }), + z.object({ + ...submissionFields, + kind: z.literal("queue"), + request: createQueuedMessageRequestSchema.extend({ + clientSubmissionId: z.string().min(1).max(128), + }), + }), + ]) + .refine( + (entry) => + entry.request.clientSubmissionId === entry.clientSubmissionId && + (entry.accepted === null || entry.accepted.kind === entry.kind), + ); +export type Submission = z.infer; +export type SubmissionAcceptance = z.infer; +export type SubmissionRequest = + | { + kind: "send"; + request: z.input & { + mode: "start" | "queue-if-active"; + }; + } + | { + kind: "queue"; + request: z.input; + }; +export type EnqueueSubmission = SubmissionRequest & { + threadId: string; + draft: { storageKey: string; value: z.infer }; + awaitAcceptance?: boolean; +}; +export type EnqueuedSubmission = Submission & { acceptance?: Promise }; +const claimSchema = z.object({ + purpose: z.enum(["delivery", "edit"]), + owner: z.string(), + token: z.string(), + expiresAt: z.number(), + submissionId: z.string(), +}); +const metadataSchema = z.object({ + threadId: z.string(), + nextSequence: z.number().int().nonnegative(), + claim: claimSchema.nullable(), +}); +export type SubmissionClaim = z.infer & { + threadId: string; + entry: Submission; +}; +export type ClaimResult = + | { claim: SubmissionClaim } + | { wakeAt: number | null }; + +let database: Promise | null = null; +let snapshot: readonly Submission[] = []; +let snapshotText = "[]"; +let storageError: Error | null = null; +const listeners = new Set<() => void>(); +const handoffs = new Map< + string, + { id: string; operation: Promise } +>(); +const acceptanceWaiters = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + timer: ReturnType; + } +>(); +const acceptanceRetentionMs = 24 * 60 * 60 * 1_000; +let observing = false; + +function openDatabase(): Promise { + if (database) return database; + database = new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + const entries = request.result.createObjectStore("submissions", { + keyPath: "id", + }); + entries.createIndex("threadSequence", ["threadId", "sequence"], { + unique: true, + }); + request.result.createObjectStore("threads", { keyPath: "threadId" }); + }; + request.onsuccess = () => { + const connection = request.result; + connection.onversionchange = () => { + connection.close(); + database = null; + }; + resolve(connection); + }; + request.onerror = () => { + database = null; + reject( + request.error ?? new Error("Message storage could not be opened."), + ); + }; + request.onblocked = () => { + database = null; + reject(new Error("Message storage is blocked by another app tab.")); + }; + }); + return database; +} + +function readRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error("Message storage could not be read.")); + }); +} + +async function transact( + mode: IDBTransactionMode, + action: (entries: IDBObjectStore, threads: IDBObjectStore) => Promise, +): Promise { + const connection = await openDatabase(); + const transaction = connection.transaction(["submissions", "threads"], mode, { + durability: "strict", + }); + const done = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => + reject( + transaction.error ?? + new Error("Message storage transaction was aborted."), + ); + transaction.onerror = () => + reject( + transaction.error ?? new Error("Message storage transaction failed."), + ); + }); + try { + const result = await action( + transaction.objectStore("submissions"), + transaction.objectStore("threads"), + ); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch { + await done.catch(() => undefined); + } + await done.catch(() => undefined); + throw error; + } +} + +async function readThread( + entries: IDBObjectStore, + threadId: string, +): Promise { + const raw = await readRequest( + entries + .index("threadSequence") + .getAll( + IDBKeyRange.bound([threadId, 0], [threadId, Number.MAX_SAFE_INTEGER]), + ), + ); + return z.array(submissionSchema).parse(raw); +} + +async function readMetadata(threads: IDBObjectStore, threadId: string) { + const raw = await readRequest(threads.get(threadId)); + return raw === undefined + ? { threadId, nextSequence: 0, claim: null } + : metadataSchema.parse(raw); +} + +function notify(): void { + for (const listener of listeners) listener(); +} + +function changed(): void { + try { + localStorage.setItem(invalidationKey, nanoid()); + } catch { + storageError = new Error( + "Other tabs may not see saved message updates until they reconnect.", + ); + } + notify(); + void refreshSubmissions(); +} + +export async function refreshSubmissions(): Promise { + try { + const entries = await transact("readonly", async (store) => + z.array(submissionSchema).parse(await readRequest(store.getAll())), + ); + entries.sort( + (left, right) => + left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); + for (const entry of entries) { + const waiter = acceptanceWaiters.get(entry.id); + if ( + !waiter || + (entry.status !== "accepted" && entry.status !== "rejected") + ) + continue; + clearTimeout(waiter.timer); + acceptanceWaiters.delete(entry.id); + if (entry.status === "accepted") { + waiter.resolve(); + void acknowledgeAcceptance(entry.id).catch(() => undefined); + } else waiter.reject(new Error(entry.error ?? "Message was rejected.")); + } + const text = JSON.stringify(entries); + const hadError = storageError !== null; + storageError = null; + if (snapshotText !== text) { + snapshot = entries; + snapshotText = text; + notify(); + } else if (hadError) notify(); + } catch (error) { + storageError = + error instanceof Error + ? error + : new Error("Saved messages could not be read."); + notify(); + } +} + +export function getSubmissions(): readonly Submission[] { + return snapshot; +} +export function getSubmissionStorageError(): Error | null { + return storageError; +} +export function subscribeSubmissions(listener: () => void): () => void { + listeners.add(listener); + if (!observing) { + observing = true; + window.addEventListener("storage", (event) => { + if (event.key === invalidationKey) { + notify(); + void refreshSubmissions(); + } + }); + } + void refreshSubmissions(); + return () => listeners.delete(listener); +} + +export function enqueueSubmission( + input: EnqueueSubmission, +): Promise { + const existing = handoffs.get(input.draft.storageKey); + if ( + existing && + getPromptDraftSubmissionHandoff(input.draft.storageKey) === existing.id + ) + return existing.operation; + const id = nanoid(); + preparePromptDraftSubmission(input.draft.storageKey, input.draft.value, id); + const acceptance = input.awaitAcceptance + ? new Promise((resolve, reject) => { + const timer = setTimeout(() => { + acceptanceWaiters.delete(id); + reject( + new Error( + "Message acceptance could not be confirmed within 24 hours. The saved message has been retained.", + ), + ); + }, acceptanceRetentionMs); + acceptanceWaiters.set(id, { resolve, reject, timer }); + }) + : undefined; + void acceptance?.catch(() => undefined); + const operation = transact("readwrite", async (entries, threads) => { + const metadata = await readMetadata(threads, input.threadId); + const entry = submissionSchema.parse({ + ...input, + request: { ...input.request, clientSubmissionId: id }, + version: 1, + id, + clientSubmissionId: id, + sequence: metadata.nextSequence, + createdAt: Date.now(), + updatedAt: Date.now(), + status: "pending", + attempted: false, + attempts: 0, + retryCount: 0, + retainAcceptanceUntil: input.awaitAcceptance + ? Date.now() + acceptanceRetentionMs + : null, + acceptanceObserved: !input.awaitAcceptance, + nextAttemptAt: 0, + error: null, + accepted: null, + reconciled: false, + handoff: { + storageKey: input.draft.storageKey, + draft: input.draft.value, + handoffId: id, + completed: false, + }, + }); + await readRequest(entries.add(entry)); + await readRequest( + threads.put({ ...metadata, nextSequence: metadata.nextSequence + 1 }), + ); + return entry; + }) + .then(async (entry) => { + changed(); + try { + await completeSubmissionHandoff(entry); + } catch { + changed(); + } + return { ...entry, acceptance }; + }) + .catch((error) => { + const waiter = acceptanceWaiters.get(id); + if (waiter) { + clearTimeout(waiter.timer); + waiter.reject( + error instanceof Error + ? error + : new Error("Message could not be saved."), + ); + acceptanceWaiters.delete(id); + } + throw error; + }); + handoffs.set(input.draft.storageKey, { id, operation }); + void operation + .finally(() => { + if ( + handoffs.get(input.draft.storageKey)?.id === id && + getPromptDraftSubmissionHandoff(input.draft.storageKey) !== id + ) + handoffs.delete(input.draft.storageKey); + }) + .catch(() => { + if (handoffs.get(input.draft.storageKey)?.id === id) + handoffs.delete(input.draft.storageKey); + }); + return operation; +} + +export async function completeSubmissionHandoff( + entry: Submission, +): Promise { + if (entry.handoff.completed) return; + completePromptDraftSubmission( + entry.handoff.storageKey, + entry.handoff.draft, + entry.handoff.handoffId, + ); + await transact("readwrite", async (entries) => { + const raw = await readRequest(entries.get(entry.id)); + if (raw === undefined) return; + const current = submissionSchema.parse(raw); + if (current.handoff.handoffId !== entry.handoff.handoffId) return; + if ( + current.status === "accepted" && + current.reconciled && + (current.acceptanceObserved || + (current.retainAcceptanceUntil ?? Infinity) <= Date.now()) + ) + await readRequest(entries.delete(entry.id)); + else + await readRequest( + entries.put({ + ...current, + handoff: { ...current.handoff, completed: true }, + }), + ); + }); + if ( + handoffs.get(entry.handoff.storageKey)?.id === entry.handoff.handoffId && + getPromptDraftSubmissionHandoff(entry.handoff.storageKey) !== + entry.handoff.handoffId + ) + handoffs.delete(entry.handoff.storageKey); + changed(); +} + +export async function claimSubmission( + threadId: string, + owner: string, + now: number, + leaseMs: number, +): Promise { + const result = await transact( + "readwrite", + async (entries, threads): Promise => { + const metadata = await readMetadata(threads, threadId); + if (metadata.claim && metadata.claim.expiresAt > now) + return { wakeAt: metadata.claim.expiresAt }; + const rows = await readThread(entries, threadId); + const entry = rows.find( + (row) => + row.status !== "rejected" && + !(row.status === "accepted" && row.reconciled), + ); + if (!entry) return { wakeAt: null }; + if (entry.nextAttemptAt > now) return { wakeAt: entry.nextAttemptAt }; + const claim = { + purpose: "delivery" as const, + owner, + token: nanoid(), + submissionId: entry.id, + expiresAt: now + leaseMs, + }; + const attempted = + entry.status === "accepted" + ? entry + : { + ...entry, + attempted: true, + attempts: entry.attempts + 1, + status: "uncertain" as const, + updatedAt: Math.max(Date.now(), entry.updatedAt + 1), + }; + await readRequest(entries.put(attempted)); + await readRequest(threads.put({ ...metadata, claim })); + return { claim: { ...claim, threadId, entry: attempted } }; + }, + ); + if ("claim" in result) changed(); + return result; +} + +export async function renewSubmissionClaim( + claim: SubmissionClaim, + now: number, + leaseMs: number, +): Promise { + return transact("readwrite", async (_entries, threads) => { + const metadata = await readMetadata(threads, claim.threadId); + if ( + metadata.claim?.token !== claim.token || + metadata.claim.expiresAt <= now + ) + return false; + await readRequest( + threads.put({ + ...metadata, + claim: { ...metadata.claim, expiresAt: now + leaseMs }, + }), + ); + return true; + }); +} + +export type SubmissionSettlement = + | { kind: "accepted"; accepted: SubmissionAcceptance } + | { + kind: "retry"; + nextAttemptAt: number; + error: string; + unattempted?: boolean; + } + | { kind: "rejected"; error: string } + | { kind: "reconciled" }; + +export async function settleSubmission( + claim: SubmissionClaim, + settlement: SubmissionSettlement, + now = Date.now(), +): Promise { + const result = await transact("readwrite", async (entries, threads) => { + const metadata = await readMetadata(threads, claim.threadId); + if ( + metadata.claim?.token !== claim.token || + metadata.claim.expiresAt <= now + ) + return false; + const current = submissionSchema.parse( + await readRequest(entries.get(claim.submissionId)), + ); + let next: Submission; + if (settlement.kind === "accepted") + next = submissionSchema.parse({ + ...current, + accepted: acceptedSchema.parse(settlement.accepted), + status: "accepted", + error: null, + nextAttemptAt: 0, + retryCount: 0, + }); + else if (settlement.kind === "reconciled") + next = { ...current, reconciled: true }; + else if (settlement.kind === "rejected") + next = { ...current, status: "rejected", error: settlement.error }; + else + next = { + ...current, + error: settlement.error, + nextAttemptAt: settlement.nextAttemptAt, + retryCount: current.retryCount + 1, + ...(settlement.unattempted && current.accepted === null + ? { + attempts: current.attempts - 1, + attempted: current.attempts > 1, + status: + current.attempts > 1 + ? ("uncertain" as const) + : ("pending" as const), + } + : {}), + }; + if ( + next.status === "accepted" && + next.reconciled && + next.handoff.completed && + (next.acceptanceObserved || + (next.retainAcceptanceUntil ?? Infinity) <= now) + ) + await readRequest(entries.delete(next.id)); + else + await readRequest( + entries.put({ + ...next, + updatedAt: Math.max(Date.now(), next.updatedAt + 1), + }), + ); + await readRequest(threads.put({ ...metadata, claim: null })); + return true; + }); + if (result) changed(); + return result; +} + +export async function editSubmission(input: { + threadId: string; + id: string; + expectedUpdatedAt: number; + input: Submission["request"]["input"]; + editToken?: string; +}): Promise { + await transact("readwrite", async (entries, threads) => { + const raw = await readRequest(entries.get(input.id)); + if (raw === undefined) + throw new Error( + "This message is no longer available. Your edits have been kept.", + ); + const current = submissionSchema.parse(raw); + const metadata = await readMetadata(threads, input.threadId); + if ( + current.threadId !== input.threadId || + current.updatedAt !== input.expectedUpdatedAt || + (current.attempted && current.status !== "rejected") || + (input.editToken !== undefined && + (metadata.claim?.purpose !== "edit" || + metadata.claim.token !== input.editToken || + metadata.claim.expiresAt <= Date.now())) || + (metadata.claim?.submissionId === current.id && + metadata.claim.token !== input.editToken) + ) + throw new Error( + "This message is already being delivered. Its contents cannot be changed.", + ); + const replacementId = + current.status === "rejected" ? nanoid() : current.clientSubmissionId; + const sequence = + current.status === "rejected" ? metadata.nextSequence : current.sequence; + const next = submissionSchema.parse({ + ...current, + request: { + ...current.request, + input: input.input, + clientSubmissionId: replacementId, + }, + clientSubmissionId: replacementId, + sequence, + updatedAt: Math.max(Date.now(), current.updatedAt + 1), + status: "pending", + attempted: false, + attempts: 0, + retryCount: 0, + error: null, + nextAttemptAt: 0, + accepted: null, + reconciled: false, + acceptanceObserved: true, + retainAcceptanceUntil: null, + }); + await readRequest(entries.put(next)); + await readRequest( + threads.put({ + ...metadata, + nextSequence: + current.status === "rejected" + ? metadata.nextSequence + 1 + : metadata.nextSequence, + claim: + metadata.claim?.token === input.editToken ? null : metadata.claim, + }), + ); + }); + changed(); +} + +export async function deleteSubmission(input: { + threadId: string; + id: string; + expectedUpdatedAt: number; +}): Promise { + await transact("readwrite", async (entries, threads) => { + const current = submissionSchema.parse( + await readRequest(entries.get(input.id)), + ); + const metadata = await readMetadata(threads, input.threadId); + if ( + current.threadId !== input.threadId || + current.updatedAt !== input.expectedUpdatedAt || + (current.attempted && current.status !== "rejected") || + metadata.claim?.submissionId === current.id + ) + throw new Error( + "This message is already being delivered. Wait for confirmation before deleting it.", + ); + await readRequest(entries.delete(current.id)); + }); + changed(); +} + +async function acknowledgeAcceptance(id: string): Promise { + await transact("readwrite", async (entries) => { + const raw = await readRequest(entries.get(id)); + if (raw === undefined) return; + const entry = submissionSchema.parse(raw); + if (entry.status !== "accepted") return; + if (entry.reconciled && entry.handoff.completed) + await readRequest(entries.delete(id)); + else await readRequest(entries.put({ ...entry, acceptanceObserved: true })); + }); + changed(); +} + +export async function pruneObservedSubmissions( + now = Date.now(), +): Promise { + await transact("readwrite", async (entries) => { + const rows = z + .array(submissionSchema) + .parse(await readRequest(entries.getAll())); + for (const entry of rows) { + if ( + entry.status === "accepted" && + entry.reconciled && + entry.handoff.completed && + (entry.acceptanceObserved || + (entry.retainAcceptanceUntil !== null && + entry.retainAcceptanceUntil <= now)) + ) + await readRequest(entries.delete(entry.id)); + } + }); + changed(); +} + +export interface SubmissionEditClaim { + threadId: string; + id: string; + token: string; + expiresAt: number; +} + +export async function acquireSubmissionEdit(input: { + threadId: string; + id: string; + expectedUpdatedAt: number; +}): Promise { + const result = await transact("readwrite", async (entries, threads) => { + const current = submissionSchema.parse( + await readRequest(entries.get(input.id)), + ); + const metadata = await readMetadata(threads, input.threadId); + if ( + current.threadId !== input.threadId || + current.updatedAt !== input.expectedUpdatedAt || + (current.attempted && current.status !== "rejected") || + (metadata.claim && metadata.claim.expiresAt > Date.now()) + ) + throw new Error( + "This message is being delivered. Wait for confirmation before editing it.", + ); + const claim = { + purpose: "edit" as const, + owner: nanoid(), + token: nanoid(), + submissionId: current.id, + expiresAt: Date.now() + 45_000, + }; + await readRequest(threads.put({ ...metadata, claim })); + return { + threadId: input.threadId, + id: current.id, + token: claim.token, + expiresAt: claim.expiresAt, + }; + }); + changed(); + return result; +} + +export async function renewSubmissionEdit( + claim: SubmissionEditClaim, +): Promise { + return transact("readwrite", async (_entries, threads) => { + const metadata = await readMetadata(threads, claim.threadId); + if ( + metadata.claim?.purpose !== "edit" || + metadata.claim.token !== claim.token || + metadata.claim.expiresAt <= Date.now() + ) + return false; + await readRequest( + threads.put({ + ...metadata, + claim: { ...metadata.claim, expiresAt: Date.now() + 45_000 }, + }), + ); + return true; + }); +} + +export async function releaseSubmissionEdit( + claim: SubmissionEditClaim, +): Promise { + await transact("readwrite", async (_entries, threads) => { + const metadata = await readMetadata(threads, claim.threadId); + if ( + metadata.claim?.purpose !== "edit" || + metadata.claim.token !== claim.token + ) + return; + await readRequest(threads.put({ ...metadata, claim: null })); + }); + changed(); +} diff --git a/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts b/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts new file mode 100644 index 00000000000..19c7b8f5131 --- /dev/null +++ b/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts @@ -0,0 +1,10 @@ +import { useMemo, useSyncExternalStore } from "react"; +import { getSubmissions, subscribeSubmissions } from "./store"; + +export function useThreadMessageSubmissions(threadId: string) { + const entries = useSyncExternalStore(subscribeSubmissions, getSubmissions); + return useMemo( + () => entries.filter((entry) => entry.threadId === threadId), + [entries, threadId], + ); +} diff --git a/apps/app/src/lib/queued-message-rows.test.ts b/apps/app/src/lib/queued-message-rows.test.ts new file mode 100644 index 00000000000..b157fcd5a9f --- /dev/null +++ b/apps/app/src/lib/queued-message-rows.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; +import type { Submission } from "@/lib/message-delivery/store"; +import { projectQueuedMessageRows } from "./queued-message-rows"; + +function submission( + overrides: Partial> = {}, +): Submission { + return { + version: 1, + kind: "queue", + id: "local-one", + clientSubmissionId: "submission-one", + threadId: "thread-one", + sequence: 1, + createdAt: 100, + updatedAt: 100, + status: "pending", + attempted: false, + attempts: 0, + retryCount: 0, + retainAcceptanceUntil: null, + acceptanceObserved: false, + nextAttemptAt: 0, + error: null, + accepted: null, + reconciled: false, + handoff: { + storageKey: "draft-one", + draft: { text: "Preserve this", mentions: [], attachments: [] }, + handoffId: "submission-one", + completed: true, + }, + request: { + input: [{ type: "text", text: "Preserve this", mentions: [] }], + clientSubmissionId: "submission-one", + }, + ...overrides, + }; +} + +describe("durable queue projection", () => { + it("keeps a local row across empty server refetches and replaces it only by submission identity", () => { + const local = submission(); + const unrelated = makeThreadQueuedMessage({ + id: "server-one", + content: local.request.input, + }); + const rows = projectQueuedMessageRows({ + serverMessages: [unrelated], + submissions: [local], + connected: false, + }); + expect(rows.map((row) => row.id)).toEqual(["server-one", "local-one"]); + expect( + projectQueuedMessageRows({ + serverMessages: [], + submissions: [local], + connected: false, + }), + ).toEqual([rows[1]]); + + const accepted = { + ...unrelated, + clientSubmissionId: local.clientSubmissionId, + }; + expect( + projectQueuedMessageRows({ + serverMessages: [accepted], + submissions: [local], + connected: true, + }), + ).toEqual([accepted]); + }); + + it("retains distinct identical messages and keeps corrected rows stable while requests use fresh IDs", () => { + const first = submission(); + const second = submission({ + id: "local-two", + clientSubmissionId: "submission-two", + }); + const corrected = submission({ + clientSubmissionId: "submission-corrected", + sequence: 3, + }); + const rows = projectQueuedMessageRows({ + serverMessages: [], + submissions: [corrected, second], + connected: true, + }); + expect(rows.map((row) => row.id)).toEqual([first.id, second.id]); + expect(rows[0]).toMatchObject({ + clientSubmissionId: "submission-corrected", + source: "local", + }); + }); + + it("freezes attempted rows and keeps definitive errors editable through refresh reconciliation", () => { + const uncertain = submission({ status: "uncertain", attempted: true }); + expect( + projectQueuedMessageRows({ + serverMessages: [], + submissions: [uncertain], + connected: true, + })[0], + ).toMatchObject({ + editable: false, + deliveryStatus: "confirming", + }); + expect( + projectQueuedMessageRows({ + serverMessages: [], + submissions: [ + { + ...uncertain, + status: "rejected", + error: "Attachment no longer exists", + }, + ], + connected: true, + })[0], + ).toMatchObject({ + editable: true, + deliveryStatus: "rejected", + error: "Attachment no longer exists", + }); + expect( + projectQueuedMessageRows({ + serverMessages: [], + submissions: [{ ...uncertain, status: "accepted", reconciled: true }], + connected: true, + }), + ).toEqual([]); + }); +}); diff --git a/apps/app/src/lib/queued-message-rows.ts b/apps/app/src/lib/queued-message-rows.ts new file mode 100644 index 00000000000..cb3650374e1 --- /dev/null +++ b/apps/app/src/lib/queued-message-rows.ts @@ -0,0 +1,87 @@ +import type { ThreadQueuedMessage } from "@bb/domain"; +import type { Submission } from "@/lib/message-delivery/store"; + +export interface LocalQueuedMessageRow extends Pick< + ThreadQueuedMessage, + "id" | "threadId" | "content" | "createdAt" | "updatedAt" +> { + model: Submission["request"]["model"]; + reasoningLevel: Submission["request"]["reasoningLevel"]; + permissionMode: Submission["request"]["permissionMode"]; + serviceTier: Submission["request"]["serviceTier"]; + source: "local"; + clientSubmissionId: string; + initiator: "user"; + senderThreadId: null; + groupWithNext: false; + editable: boolean; + deliveryStatus: "waiting" | "confirming" | "rejected"; + error: string | null; +} + +export type QueuedMessageRow = + | (ThreadQueuedMessage & { source?: "server" }) + | LocalQueuedMessageRow; + +export function isLocalQueuedMessage( + message: QueuedMessageRow, +): message is LocalQueuedMessageRow { + return message.source === "local"; +} + +export function getServerQueuedMessages( + messages: readonly QueuedMessageRow[], +): ThreadQueuedMessage[] { + return messages.filter( + (message): message is ThreadQueuedMessage => !isLocalQueuedMessage(message), + ); +} + +export function projectQueuedMessageRows({ + serverMessages, + submissions, + connected, +}: { + serverMessages: readonly ThreadQueuedMessage[]; + submissions: readonly Submission[]; + connected: boolean; +}): QueuedMessageRow[] { + const acceptedIds = new Set( + serverMessages.flatMap((message) => + message.clientSubmissionId ? [message.clientSubmissionId] : [], + ), + ); + const localMessages = submissions + .filter( + (submission) => + !submission.reconciled && + !acceptedIds.has(submission.clientSubmissionId), + ) + .map((submission): LocalQueuedMessageRow => ({ + source: "local", + id: submission.id, + threadId: submission.threadId, + clientSubmissionId: submission.clientSubmissionId, + content: submission.request.input, + model: submission.request.model, + reasoningLevel: submission.request.reasoningLevel, + permissionMode: submission.request.permissionMode, + serviceTier: submission.request.serviceTier, + initiator: "user", + senderThreadId: null, + groupWithNext: false, + createdAt: submission.createdAt, + updatedAt: submission.updatedAt, + editable: + submission.status === "rejected" || + (submission.status === "pending" && !submission.attempted), + deliveryStatus: + submission.status === "rejected" + ? "rejected" + : connected || submission.status === "accepted" + ? "confirming" + : "waiting", + error: submission.status === "rejected" ? submission.error : null, + })); + return [...serverMessages, ...localMessages]; +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 9bafcc33b7b..52b132a4a66 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -51,6 +51,9 @@ import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; const mocks = vi.hoisted(() => ({ createQueuedMessageIsPending: false, + durableEnabled: false, + serverConnected: true, + durableEnqueue: vi.fn(), cancelThreadPlanMutate: vi.fn(), clearThreadGoalMutate: vi.fn(), createQueuedMessageMutateAsync: vi.fn(), @@ -100,6 +103,21 @@ vi.mock("react-router-dom", async (importOriginal) => { }; }); +vi.mock("@/hooks/useDurableMessageSubmission", () => ({ + useDurableMessageSubmission: () => ({ + enabled: mocks.durableEnabled, + connected: mocks.serverConnected, + isSaving: false, + enqueue: mocks.durableEnqueue, + }), + isDurableMessageInput: () => true, +})); + +vi.mock("@/hooks/useQueuedMessageRows", () => ({ + useQueuedMessageRows: (_threadId: string, messages: readonly unknown[]) => + messages, +})); + vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { const { ComposerBannersSlot } = await vi.importActual< typeof import("@/components/plugin/PluginComposerBanners") @@ -131,6 +149,8 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { composer: { canModifierSubmit: boolean; message: string; + submitDisabled?: boolean; + isFollowUpSubmitting?: boolean; onChangeMessage: (message: string, mentions: []) => void; onEscape?: () => void; onModifierSubmit: () => void; @@ -296,12 +316,17 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { <> composer.onChangeMessage(event.currentTarget.value, []) } /> - {composer.canModifierSubmit ? ( @@ -898,6 +923,9 @@ function renderPromptArea(options: RenderPromptAreaOptions = {}) { beforeEach(() => { mocks.createQueuedMessageIsPending = false; + mocks.durableEnabled = false; + mocks.serverConnected = true; + mocks.durableEnqueue.mockReset().mockResolvedValue(undefined); testQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -959,6 +987,107 @@ describe("environment follow-up summary", () => { }); describe("ThreadDetailPromptArea", () => { + it("disables idle Send while disconnected and keeps the draft editable", () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + mocks.promptDraft.text = "An offline draft"; + renderPromptArea({ + thread: makeThread({ + status: "idle", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + }), + }); + const composer = screen.getByRole("textbox", { + name: "Composer message", + }); + expect( + screen.getByRole("button", { name: "Submit composer" }) + .disabled, + ).toBe(true); + expect(composer.readOnly).toBe(false); + expect(screen.getByTestId("submit-title").textContent).toBe( + "Waiting for connection", + ); + fireEvent.change(composer, { target: { value: "Still writing offline" } }); + expect(mocks.promptDraft.setTextAndMentions).toHaveBeenCalledWith( + "Still writing offline", + [], + ); + expect(mocks.durableEnqueue).not.toHaveBeenCalled(); + expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); + }); + + it("saves an offline busy-thread message through durable delivery without a second mutation or draft clear", async () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + mocks.promptDraft.text = "Save before reconnect"; + const saved = createDeferredPromise(); + mocks.durableEnqueue.mockReturnValueOnce(saved.promise); + renderPromptArea({ + thread: makeThread({ + status: "active", + runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, + }), + }); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + expect(mocks.durableEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "queue", + request: expect.objectContaining({ + input: [ + { type: "text", text: "Save before reconnect", mentions: [] }, + ], + }), + }), + { + storageKey: mocks.promptDraft.storageKey, + value: { text: "Save before reconnect", mentions: [], attachments: [] }, + }, + ); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + await act(async () => { + saved.resolve(); + }); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); + const composer = screen.getByRole("textbox", { + name: "Composer message", + }); + expect(composer.readOnly).toBe(false); + fireEvent.change(composer, { target: { value: "Another draft" } }); + expect(mocks.promptDraft.setTextAndMentions).toHaveBeenCalledWith( + "Another draft", + [], + ); + }); + + it("keeps the prompt intact if durable queue persistence fails", async () => { + mocks.durableEnabled = true; + mocks.serverConnected = false; + mocks.promptDraft.text = "Keep this unsaved submission"; + mocks.durableEnqueue.mockRejectedValueOnce( + new Error("Message storage is full"), + ); + renderPromptArea({ + thread: makeThread({ + status: "active", + runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, + }), + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + }); + expect( + screen.getByRole("textbox", { + name: "Composer message", + }).value, + ).toBe("Keep this unsaved submission"); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); + expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); + }); + it("does not resubmit a draft while another composer owns its pending request", () => { mocks.createQueuedMessageIsPending = true; mocks.promptDraft.text = "Already submitting"; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 26527210c86..14b10eb9dad 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -74,6 +74,12 @@ import type { MachineLabelHost } from "@/components/machines/MachineLabel"; import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; +import { useQueuedMessageRows } from "@/hooks/useQueuedMessageRows"; +import { getServerQueuedMessages } from "@/lib/queued-message-rows"; +import { + isDurableMessageInput, + useDurableMessageSubmission, +} from "@/hooks/useDurableMessageSubmission"; import { useLatestRef } from "@/hooks/useLatestRef"; import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query"; @@ -425,11 +431,15 @@ export function ThreadDetailPromptArea({ const queuedMessagesQuery = useThreadQueuedMessages(thread.id, { enabled: true, }); - const queuedMessages = queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES; + const serverQueuedMessages = + queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES; + const queuedMessages = useQueuedMessageRows(thread.id, serverQueuedMessages); + const durableSubmission = useDurableMessageSubmission(thread.id); const queuedMessagesPending = - queuedMessagesQuery.data === undefined && queuedMessageCount > 0; - const queuedMessagesRef = - useLatestRef(queuedMessages); + queuedMessagesQuery.data === undefined && + queuedMessageCount > 0 && + queuedMessages.length === 0; + const queuedMessagesRef = useLatestRef(queuedMessages); const [bottomPluginFocusNonce, setBottomPluginFocusNonce] = useState(0); const [editFocusNonce, setEditFocusNonce] = useState(0); const focusBottomPluginComposer = useCallback(() => { @@ -869,6 +879,7 @@ export function ThreadDetailPromptArea({ queuedMessageActionPending || isFollowUpShortcutSending; const isFollowUpSubmitting = + durableSubmission.isSaving || sendMessage.isPending || createQueuedMessage.isPending || createThread.isPending || @@ -951,14 +962,16 @@ export function ThreadDetailPromptArea({ ], ); const hasPromptDraftInput = currentPromptDraftInput.length > 0; - const canSubmitModifierShortcut = canSubmitFollowUpShortcut({ - hasPromptDraftInput, - isFollowUpSubmitting, - isQueueMutationPending, - queuedMessageCount: queuedMessages.length, - runtimeDisplayStatus, - submitModeKind: submitMode.kind, - }); + const canSubmitModifierShortcut = + durableSubmission.connected && + canSubmitFollowUpShortcut({ + hasPromptDraftInput, + isFollowUpSubmitting, + isQueueMutationPending, + queuedMessageCount: queuedMessages.length, + runtimeDisplayStatus, + submitModeKind: submitMode.kind, + }); const followUpExecutionSelection = useMemo(() => { if (!hasConcreteDefaultExecutionOptions) { return null; @@ -1034,7 +1047,11 @@ export function ThreadDetailPromptArea({ ); const handleSend = useCallback(async () => { - if (isFollowUpSubmitting) { + if ( + isFollowUpSubmitting || + (!durableSubmission.connected && + !shouldQueueFollowUpMessage(runtimeDisplayStatus)) + ) { return; } const submittedDraft = currentPromptDraft; @@ -1063,8 +1080,19 @@ export function ThreadDetailPromptArea({ execution: followUpExecutionSelection, }); if (request) { - await createQueuedMessage.mutateAsync(request); - promptDraft.clearIfCurrentMatches(submittedDraft); + if ( + durableSubmission.enabled && + isDurableMessageInput(submittedInput) + ) { + const { id: _threadId, ...payload } = request; + await durableSubmission.enqueue( + { kind: "queue", request: payload }, + { storageKey: promptDraft.storageKey, value: submittedDraft }, + ); + } else { + await createQueuedMessage.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); + } } } else { const request = buildAutoFollowUpRequest({ @@ -1073,8 +1101,19 @@ export function ThreadDetailPromptArea({ execution: followUpExecutionSelection, }); if (request) { - await sendMessage.mutateAsync(request); - promptDraft.clearIfCurrentMatches(submittedDraft); + if ( + durableSubmission.enabled && + isDurableMessageInput(submittedInput) + ) { + const { id: _threadId, ...payload } = request; + await durableSubmission.enqueue( + { kind: "send", request: payload }, + { storageKey: promptDraft.storageKey, value: submittedDraft }, + ); + } else { + await sendMessage.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); + } } } } catch (nextError) { @@ -1089,6 +1128,7 @@ export function ThreadDetailPromptArea({ }, [ createHandoffThread, createQueuedMessage, + durableSubmission, currentPromptDraft, currentPromptDraftInput, followUpExecutionSelection, @@ -1106,6 +1146,12 @@ export function ThreadDetailPromptArea({ submitOptions: ExperimentalComposerSubmitOptions, pluginSubmission: SendMessageRequest["pluginSubmission"], ) => { + if ( + !durableSubmission.connected && + !shouldQueueFollowUpMessage(runtimeDisplayStatus) + ) { + throw new Error("Waiting for connection."); + } if (isFollowUpSubmitting) { throw new Error("A message is still being submitted."); } @@ -1148,14 +1194,25 @@ export function ThreadDetailPromptArea({ } setBottomAttachmentError(null); try { - await sendMessage.mutateAsync({ + const submissionRequest = { ...request, ...(submitOptions.sendAt === undefined ? {} : { sendAt: submitOptions.sendAt }), ...(pluginSubmission === undefined ? {} : { pluginSubmission }), - }); - promptDraft.clearIfCurrentMatches(submittedDraft); + }; + if (durableSubmission.enabled && isDurableMessageInput(request.input)) { + const { id: _threadId, ...payload } = submissionRequest; + const saved = await durableSubmission.enqueue( + { kind: "send", request: payload }, + { storageKey: promptDraft.storageKey, value: submittedDraft }, + true, + ); + await saved.acceptance; + } else { + await sendMessage.mutateAsync(submissionRequest); + promptDraft.clearIfCurrentMatches(submittedDraft); + } } catch (scheduleError) { throw new Error( getMutationErrorMessage({ @@ -1168,6 +1225,8 @@ export function ThreadDetailPromptArea({ }, [ createHandoffThread, + durableSubmission, + runtimeDisplayStatus, effectiveSelectedModel, followUpExecutionSelection, isDefaultExecutionOptionsLoading, @@ -1193,7 +1252,7 @@ export function ThreadDetailPromptArea({ const shortcutRequest = buildFollowUpShortcutRequest({ execution: followUpExecutionSelection, input: submittedInput, - queuedMessages: queuedMessagesRef.current, + queuedMessages: getServerQueuedMessages(queuedMessagesRef.current), threadId: thread.id, }); if (!shortcutRequest) { @@ -1326,12 +1385,21 @@ export function ThreadDetailPromptArea({ compactPromptPlaceholder, promptPlaceholder, canModifierSubmit: canSubmitModifierShortcut, - steerActiveThreadOnEnter, + steerActiveThreadOnEnter: + durableSubmission.connected && steerActiveThreadOnEnter, + submitDisabled: + !durableSubmission.connected && + !shouldQueueFollowUpMessage(runtimeDisplayStatus), + ...(!durableSubmission.connected && + !shouldQueueFollowUpMessage(runtimeDisplayStatus) + ? { submitTitle: "Waiting for connection" } + : {}), submitMode, threadRuntimeDisplayStatus: runtimeDisplayStatus, }), [ canSubmitModifierShortcut, + durableSubmission.connected, compactPromptPlaceholder, currentPromptDraft, handleBottomComposerModifierSubmit, @@ -1480,16 +1548,22 @@ export function ThreadDetailPromptArea({ ...compactExecutionConfig, model: { ...compactExecutionConfig.model, - active: { model: inlineEditingQueuedMessage.model }, - selected: inlineEditingQueuedMessage.model, + active: inlineEditingQueuedMessage.model + ? { model: inlineEditingQueuedMessage.model } + : undefined, + selected: inlineEditingQueuedMessage.model ?? "", }, serviceTier: { ...compactExecutionConfig.serviceTier, - value: inlineEditingQueuedMessage.serviceTier, + value: + inlineEditingQueuedMessage.serviceTier ?? + compactExecutionConfig.serviceTier?.value, }, reasoning: { ...compactExecutionConfig.reasoning, - value: inlineEditingQueuedMessage.reasoningLevel, + value: + inlineEditingQueuedMessage.reasoningLevel ?? + compactExecutionConfig.reasoning.value, }, }; }, [compactExecutionConfig, inlineEditingQueuedMessage]); @@ -1905,6 +1979,7 @@ export function ThreadDetailPromptArea({ inlineEditor={queuedMessageEditor ?? undefined} sendAction={shouldSteerWhenReady ? "steer-when-ready" : "send-now"} sendDisabled={ + !durableSubmission.connected || submitMode.kind === "blocked" || runtimeDisplayStatus === "waiting-for-host" || isFollowUpSubmitting || @@ -1924,6 +1999,7 @@ export function ThreadDetailPromptArea({ ), [ canUseGitUi, + durableSubmission.connected, childPendingInteractionBanners, contextBannerMergeBase, environmentHostId, diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index 3a45f1e6659..b69b478da2b 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -37,6 +37,49 @@ describe("bb thread tell command output", () => { }); }); + it("forwards submission IDs and uses follow-up mode for ordinary durable messages", async () => { + const post = vi.fn(async () => ({ ok: true, delivery: "sent" })); + const create = vi.fn(async () => ({ id: "qmsg_submission" })); + stubServerApi({ + "v1.threads.:id.send.$post": post, + "v1.threads.:id.queued-messages.$post": create, + }); + await runCommand( + [ + "thread", + "tell", + "thread-submission", + "hello", + "--submission-id", + "send_nanoid", + ], + register, + ); + await runCommand( + [ + "thread", + "queue", + "create", + "thread-submission", + "hello", + "--submission-id", + "queue_nanoid", + ], + register, + ); + expect(post).toHaveBeenCalledWith({ + param: { id: "thread-submission" }, + json: expect.objectContaining({ + clientSubmissionId: "send_nanoid", + mode: "queue-if-active", + }), + }); + expect(create).toHaveBeenCalledWith({ + param: { id: "thread-submission" }, + json: expect.objectContaining({ clientSubmissionId: "queue_nanoid" }), + }); + }); + it("bb thread tell names the typed reason a message queued for", async () => { // The server says WHY, so the CLI stops inferring it from the flags it // sent — which is what let the old four-way delivery enum collapse. diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index a10bf2efd5c..044d849da83 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -68,6 +68,7 @@ interface ThreadDeleteCommandOptions { } interface ThreadTellCommandOptions { + submissionId?: string; json?: boolean; model?: string; permissionMode?: string; @@ -103,6 +104,7 @@ interface ThreadEditMessageCommandOptions { type ThreadTellDeliveryMode = "auto" | "queue" | "steer"; interface PostThreadMessageArgs { + clientSubmissionId?: string; getUrl: () => string; threadId: string; message: string; @@ -426,6 +428,10 @@ export function registerActionsCommands( parent .command("tell ") .description("Send a follow-up message to a thread") + .option( + "--submission-id ", + "Stable ID for retrying an ordinary follow-up without duplicate admission", + ) .option("--json", "Print machine-readable JSON output") .option("--model ", "Model ID for this message") .option("--service-tier ", "Service tier: fast or default") @@ -456,10 +462,14 @@ export function registerActionsCommands( action( async (id: string, message: string, opts: ThreadTellCommandOptions) => { const response = await postThreadMessage({ + clientSubmissionId: opts.submissionId, getUrl, threadId: id, message, - mode: resolveThreadMessageMode(opts.mode), + mode: + opts.submissionId !== undefined && opts.mode === undefined + ? "queue" + : resolveThreadMessageMode(opts.mode), model: opts.model, permissionMode: parsePermissionMode(opts.permissionMode), reasoningLevel: parseReasoningLevel(opts.reasoningLevel), @@ -576,6 +586,9 @@ async function postThreadMessage( sdk, }); const response = await sdk.threads.send({ + ...(args.clientSubmissionId === undefined + ? {} + : { clientSubmissionId: args.clientSubmissionId }), threadId: args.threadId, input, mode: diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index abc765f12b2..9854584ca1e 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -48,6 +48,7 @@ interface QueueListOptions extends JsonOptions { } interface QueueCreateOptions extends JsonOptions { + submissionId?: string; model?: string; } @@ -343,6 +344,10 @@ export function registerOrganizationCommands( queue .command("create ") .description("Create a queued text message") + .option( + "--submission-id ", + "Stable ID for retrying the same queued follow-up without duplicates", + ) .option("--model ", "Model override for the queued message") .option("--json", "Print machine-readable JSON output") .action( @@ -351,6 +356,9 @@ export function registerOrganizationCommands( const result = await createCliBbSdk( getUrl(), ).threads.queuedMessages.create({ + ...(opts.submissionId === undefined + ? {} + : { clientSubmissionId: opts.submissionId }), threadId, input: [{ type: "text", text: message, mentions: [] }], ...(opts.model ? { model: opts.model } : {}), diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 9953591c12f..31809ce63d7 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -207,7 +207,10 @@ export function registerSystemRoutes( ), customThemes: listCustomThemeNames(themeRoot), pluginThemes: pluginService.listThemes(), - featureFlags: deps.config.featureFlags, + featureFlags: { + ...deps.config.featureFlags, + durableMessageDelivery: true, + }, hostDaemonPort: deps.config.hostDaemonPort, localHelperPorts, serverUrl, diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index d59d290df49..c35e0bdf18a 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -1,3 +1,7 @@ +import { + acceptThreadSubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { requestQueuedMachineReadiness } from "./queued-message-dispatch.js"; import { cancelPreparingMachinePause, @@ -216,6 +220,7 @@ export type DispatchAttemptSource = }; export interface DispatchAttemptArgs { + submission?: ThreadSubmissionReceipt; thread: Thread; payload: SendMessageRequest & { inputGroups?: PromptInput[][] }; source: DispatchAttemptSource; @@ -345,6 +350,7 @@ async function runDispatchAttempt( ); const resolvedPayload = resolveExecutionIntoPayload(payload, execution); const queuedMessage: QueuedDispatchMessage = { + submission: args.submission, input: payload.input, execution, senderThreadId, @@ -485,6 +491,7 @@ async function runDispatchAttempt( if (firstDispatch) { admitted.value = await admitPendingThread(deps, { + submission: args.submission, claimed, payload: resolvedPayload, respectManualStopPause, @@ -580,6 +587,7 @@ async function runDispatchAttempt( const environment = await requireThreadCommandEnvironment(deps, { thread }); try { await sendThreadMessage(deps, { + submission: args.submission, environment, payload: resolvedPayload, thread, @@ -661,6 +669,7 @@ function consumeClaimedRows( } interface AdmitPendingThreadArgs { + submission?: ThreadSubmissionReceipt; claimed: ClaimedQueuedThreadMessageRow[] | null; payload: SendMessageRequest & { inputGroups?: PromptInput[][] }; respectManualStopPause: boolean; @@ -724,41 +733,53 @@ async function admitPendingThread( let startingThread: Thread; try { startingThread = deps.db.transaction( - (tx) => { - // The row is consumed and the thread flipped in ONE transaction: a - // flip that loses to a concurrent attempt rolls the consumption back, - // so the row stays claimed for the caller to hand back rather than - // being deleted under a message that never dispatched. - if (args.claimed !== null && args.claimed.length > 0) { - consumeClaimedRows( - args.claimed, - args.thread.id, - args.respectManualStopPause, - )({ tx }); - } - const prepared = applyLoggedThreadLifecycleEventInTransaction( - { db: tx, logger: deps.logger }, - { threadId: args.thread.id, event: { type: "run.preparing" } }, - ); - if (!prepared.applied) { - throw new PendingThreadAdmissionLost(); - } - const starting = getThread(tx, args.thread.id); - if (starting === null) throw new PendingThreadAdmissionLost(); - requestThreadProvision(deps, { - thread: starting, - environmentIntent: startContext.environmentIntent, - execution, - fork: startContext.fork, - input: args.payload.input, - ...(startContext.providerInput === undefined - ? {} - : { providerInput: startContext.providerInput }), - startedOnBehalfOf: startContext.startedOnBehalfOf, - titleProvided: startContext.titleProvided, - }); - return starting; - }, + (tx) => + acceptThreadSubmission( + tx, + args.submission, + () => { + // The row is consumed and the thread flipped in ONE transaction: a + // flip that loses to a concurrent attempt rolls the consumption back, + // so the row stays claimed for the caller to hand back rather than + // being deleted under a message that never dispatched. + if (args.claimed !== null && args.claimed.length > 0) { + consumeClaimedRows( + args.claimed, + args.thread.id, + args.respectManualStopPause, + )({ tx }); + } + const prepared = applyLoggedThreadLifecycleEventInTransaction( + { db: tx, logger: deps.logger }, + { threadId: args.thread.id, event: { type: "run.preparing" } }, + ); + if (!prepared.applied) { + throw new PendingThreadAdmissionLost(); + } + const starting = getThread(tx, args.thread.id); + if (starting === null) throw new PendingThreadAdmissionLost(); + const provision = requestThreadProvision(deps, { + thread: starting, + environmentIntent: startContext.environmentIntent, + execution, + fork: startContext.fork, + input: args.payload.input, + ...(startContext.providerInput === undefined + ? {} + : { providerInput: startContext.providerInput }), + startedOnBehalfOf: startContext.startedOnBehalfOf, + titleProvided: startContext.titleProvided, + }); + return { + starting, + turnRequestId: provision.request.clientRequestId, + }; + }, + (accepted) => ({ + delivery: "sent", + turnRequestId: accepted.turnRequestId, + }), + ).starting, { behavior: "immediate" }, ); } catch (error) { diff --git a/apps/server/src/services/threads/queue-waits.ts b/apps/server/src/services/threads/queue-waits.ts index 6c015e6b509..b1a3796af43 100644 --- a/apps/server/src/services/threads/queue-waits.ts +++ b/apps/server/src/services/threads/queue-waits.ts @@ -1,3 +1,7 @@ +import { + acceptThreadSubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { clearQueuedThreadMessageWaitingOn, createQueuedThreadMessageInTransaction, @@ -59,6 +63,7 @@ export interface SettleQueueRowArgs { /** The message a queued row will carry, as the queueing site supplies it. */ export interface QueuedDispatchMessage { + submission?: ThreadSubmissionReceipt; input: PromptInput[]; execution: ResolvedThreadExecutionOptions; senderThreadId: string | null; @@ -110,19 +115,29 @@ export function recordQueuedMessageWait( if (leadClaim === undefined) { row = deps.db.transaction( (tx) => - createQueuedThreadMessageInTransaction(tx, { - threadId: args.thread.id, - content: args.message.input, - senderThreadId: args.message.senderThreadId, - model: args.message.execution.model, - reasoningLevel: args.message.execution.reasoningLevel, - permissionMode: args.message.execution.permissionMode, - serviceTier: args.message.execution.serviceTier, - waitingOn: args.waitingOn, - sendAt: args.sendAt, - payload: args.message.payload, - systemNotice: args.message.systemNotice, - }), + acceptThreadSubmission( + tx, + args.message.submission, + () => + createQueuedThreadMessageInTransaction(tx, { + clientSubmissionId: args.message.submission?.id, + threadId: args.thread.id, + content: args.message.input, + senderThreadId: args.message.senderThreadId, + model: args.message.execution.model, + reasoningLevel: args.message.execution.reasoningLevel, + permissionMode: args.message.execution.permissionMode, + serviceTier: args.message.execution.serviceTier, + waitingOn: args.waitingOn, + sendAt: args.sendAt, + payload: args.message.payload, + systemNotice: args.message.systemNotice, + }), + (row) => ({ + delivery: "queued", + queuedMessage: toThreadQueuedMessage(row), + }), + ), { behavior: "immediate" }, ); } else { diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 9e5049ec76c..0d998ecddb5 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -1,3 +1,9 @@ +import { + acceptThreadSubmission, + withThreadSubmissionReceipt, + requireOrdinarySubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { claimNextQueuedThreadMessageGroup, claimQueuedThreadMessageGroup, @@ -145,7 +151,10 @@ export function createAutomaticQueuedMessageGroupEligibility( case "thread-busy": case "stopping": return ( - args.thread.status === "idle" || args.thread.status === "pending" + args.thread.status === "idle" || + args.thread.status === "pending" || + (args.thread.status === "error" && + member.clientSubmissionId !== null) ); case "turn-starting": return ( @@ -214,8 +223,36 @@ function admitQueuedMessage( export async function createQueuedMessageForThread( deps: LoggedPendingInteractionWorkSessionDeps, args: CreateQueuedMessageForThreadArgs, +): Promise { + const response = await withThreadSubmissionReceipt( + deps.db, + { + threadId: args.thread.id, + operation: "queue", + payload: args.payload, + }, + async (submission) => ({ + ok: true, + delivery: "queued", + queuedMessage: await createQueuedMessageForThreadOnce( + deps, + args, + submission, + ), + }), + ); + if (response.delivery !== "queued") + throw new Error("Invalid queued submission receipt"); + return response.queuedMessage; +} + +async function createQueuedMessageForThreadOnce( + deps: LoggedPendingInteractionWorkSessionDeps, + args: CreateQueuedMessageForThreadArgs, + submission: ThreadSubmissionReceipt | undefined, ): Promise { const { payload, thread } = args; + if (submission !== undefined) requireOrdinarySubmission(payload.input); ensureThreadQueueIsWritable(thread); await validatePromptAttachmentReferences({ db: deps.db, @@ -232,39 +269,52 @@ export async function createQueuedMessageForThread( }); const { currentThread, hasProviderSession, queuedMessage } = deps.db.transaction( - (tx) => { - const currentThread = getThread(tx, thread.id); - if (!currentThread) { - throw new ApiError(404, "thread_not_found", "Thread not found"); - } - const { hasProviderSession } = admitQueuedMessage(tx, currentThread); - const queuedMessage = createQueuedThreadMessageInTransaction(tx, { - threadId: thread.id, - content: payload.input, - senderThreadId, - model: execution.model, - reasoningLevel: execution.reasoningLevel, - permissionMode: execution.permissionMode, - serviceTier: execution.serviceTier, - // An explicit "queue this" is a message waiting for the running turn - // to end, which is exactly `thread-busy`. Naming it rather than - // leaving the wait null keeps every row on one vocabulary, and the - // idle drain treats the two identically anyway. - // - // Queued while the thread is stopping, it is instead a message the - // user composed AFTER asking for the stop, so it carries `stopping` - // and runs when the stop lands rather than joining the rows the - // manual-stop pause holds back. - waitingOn: - currentThread.status === "stopping" - ? { kind: "stopping" } - : { kind: "thread-busy" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - return { currentThread, hasProviderSession, queuedMessage }; - }, + (tx) => + acceptThreadSubmission( + tx, + submission, + () => { + const currentThread = getThread(tx, thread.id); + if (!currentThread) { + throw new ApiError(404, "thread_not_found", "Thread not found"); + } + const { hasProviderSession } = admitQueuedMessage( + tx, + currentThread, + ); + const queuedMessage = createQueuedThreadMessageInTransaction(tx, { + clientSubmissionId: submission?.id, + threadId: thread.id, + content: payload.input, + senderThreadId, + model: execution.model, + reasoningLevel: execution.reasoningLevel, + permissionMode: execution.permissionMode, + serviceTier: execution.serviceTier, + // An explicit "queue this" is a message waiting for the running turn + // to end, which is exactly `thread-busy`. Naming it rather than + // leaving the wait null keeps every row on one vocabulary, and the + // idle drain treats the two identically anyway. + // + // Queued while the thread is stopping, it is instead a message the + // user composed AFTER asking for the stop, so it carries `stopping` + // and runs when the stop lands rather than joining the rows the + // manual-stop pause holds back. + waitingOn: + currentThread.status === "stopping" + ? { kind: "stopping" } + : { kind: "thread-busy" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + return { currentThread, hasProviderSession, queuedMessage }; + }, + (accepted) => ({ + delivery: "queued", + queuedMessage: toThreadQueuedMessage(accepted.queuedMessage), + }), + ), { behavior: "immediate" }, ); deps.hub.notifyThread(thread.id, ["queue-changed"]); @@ -275,7 +325,11 @@ export async function createQueuedMessageForThread( providerId: thread.providerId, }); } - if (currentThread.status === "idle" && hasProviderSession) { + if ( + (currentThread.status === "idle" && hasProviderSession) || + (submission !== undefined && + ["idle", "error", "pending"].includes(currentThread.status)) + ) { requestQueuedMessageDispatch(deps, { kind: "thread-ready", threadId: thread.id, @@ -697,6 +751,10 @@ async function sendClaimedQueuedMessageForThread( lead.senderThreadId, ), ...(inputGroups.length > 1 ? { inputGroups } : {}), + ...(args.mode === "auto" && + queuedMessages.some((entry) => entry.clientSubmissionId !== undefined) + ? { mode: "queue-if-active" as const } + : {}), }, source: { kind: "drain", diff --git a/apps/server/src/services/threads/thread-provisioning.ts b/apps/server/src/services/threads/thread-provisioning.ts index 9d88495fb86..470e83d049f 100644 --- a/apps/server/src/services/threads/thread-provisioning.ts +++ b/apps/server/src/services/threads/thread-provisioning.ts @@ -1,3 +1,7 @@ +import { + acceptThreadSubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { getNonDestroyedHostByLaunchKey } from "@bb/db"; import { sweepProviderMachine } from "../machines/provider-orchestration.js"; import { cancelProviderEnvironmentCreation } from "../environments/environment-engine.js"; @@ -62,6 +66,7 @@ interface RequestThreadProvisionArgs { } interface RequestThreadTargetReprovisionArgs { + submission?: ThreadSubmissionReceipt; beforeRequestAppendInTransaction?: (args: { tx: DbTransaction }) => void; environment: EnvironmentRow; execution: ResolvedThreadExecutionOptions; @@ -317,42 +322,48 @@ function appendReprovisionTurnRequest( ) { const requestId = createClientTurnRequestId(); const request = deps.db.transaction( - (tx) => { - args.beforeRequestAppendInTransaction?.({ tx }); - const request = - appendPreparedClientTurnRequestedEventWithNotificationInTransaction( - tx, - { - threadId: args.thread.id, - environmentId: args.environment.id, - type: "client/turn/requested", - input: args.input, - ...(args.inputGroups !== undefined - ? { inputGroups: args.inputGroups } - : {}), - execution: args.execution, - initiator: args.initiator, - senderThreadId: args.senderThreadId, - systemMessageKind: args.systemMessageKind, - systemMessageSubject: args.systemMessageSubject, - requestMethod: "turn/start", - source: "tell", - target: { kind: "new-turn" }, - requestId, - }, - ); - recordAcceptedPromptHistoryEntry( - { db: tx }, - { - thread: args.thread, - input: args.input, - initiator: args.initiator, - target: { kind: "new-turn" }, - requestSequence: request.sequence, + (tx) => + acceptThreadSubmission( + tx, + args.submission, + () => { + args.beforeRequestAppendInTransaction?.({ tx }); + const request = + appendPreparedClientTurnRequestedEventWithNotificationInTransaction( + tx, + { + threadId: args.thread.id, + environmentId: args.environment.id, + type: "client/turn/requested", + input: args.input, + ...(args.inputGroups !== undefined + ? { inputGroups: args.inputGroups } + : {}), + execution: args.execution, + initiator: args.initiator, + senderThreadId: args.senderThreadId, + systemMessageKind: args.systemMessageKind, + systemMessageSubject: args.systemMessageSubject, + requestMethod: "turn/start", + source: "tell", + target: { kind: "new-turn" }, + requestId, + }, + ); + recordAcceptedPromptHistoryEntry( + { db: tx }, + { + thread: args.thread, + input: args.input, + initiator: args.initiator, + target: { kind: "new-turn" }, + requestSequence: request.sequence, + }, + ); + return request; }, - ); - return request; - }, + (accepted) => ({ delivery: "sent", turnRequestId: accepted.requestId }), + ), { behavior: "immediate" }, ); deps.hub.notifyThread( diff --git a/apps/server/src/services/threads/thread-queued-messages.ts b/apps/server/src/services/threads/thread-queued-messages.ts index 15f2b83f88b..0b355184ee7 100644 --- a/apps/server/src/services/threads/thread-queued-messages.ts +++ b/apps/server/src/services/threads/thread-queued-messages.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { ApiError } from "../../errors.js"; interface StoredQueuedThreadMessageRow { + clientSubmissionId?: string | null; claimedAt: number | null; content: string; createdAt: number; @@ -131,6 +132,9 @@ export function toThreadQueuedMessage( : row.senderThreadId !== null ? "agent" : "user", + ...(row.clientSubmissionId == null + ? {} + : { clientSubmissionId: row.clientSubmissionId }), senderThreadId: row.senderThreadId, threadId: row.threadId, content: parseStoredQueuedThreadMessageContent(row), diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 132257c3c4f..d9e4009e154 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -1,3 +1,8 @@ +import { + withThreadSubmissionReceipt, + requireOrdinarySubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { isStandaloneBuiltinClearCommand, type Thread } from "@bb/domain"; import type { SendMessageRequest, @@ -17,6 +22,24 @@ export async function acceptThreadSendRequest( deps: LoggedPendingInteractionWorkSessionDeps, args: AcceptThreadSendRequestArgs, ): Promise { + return withThreadSubmissionReceipt( + deps.db, + { + threadId: args.thread.id, + operation: "send", + payload: args.payload, + }, + (submission) => acceptThreadSendRequestOnce(deps, args, submission), + ); +} + +async function acceptThreadSendRequestOnce( + deps: LoggedPendingInteractionWorkSessionDeps, + args: AcceptThreadSendRequestArgs, + submission: ThreadSubmissionReceipt | undefined, +): Promise { + if (submission !== undefined) + requireOrdinarySubmission(args.payload.input, args.payload.mode); if (isStandaloneBuiltinClearCommand(args.payload.input)) { const environment = await requireThreadCommandEnvironment(deps, { thread: args.thread, @@ -32,7 +55,11 @@ export async function acceptThreadSendRequest( const outcome = await attemptDispatch(deps, { thread: args.thread, - payload: args.payload, + payload: + submission === undefined + ? args.payload + : { ...args.payload, mode: "queue-if-active" }, + submission, source: { kind: "inline" }, queuePayload: { kind: "inline" }, pluginSubmission: args.payload.pluginSubmission ?? null, diff --git a/apps/server/src/services/threads/thread-send.ts b/apps/server/src/services/threads/thread-send.ts index 9cd71b847de..0d169211a13 100644 --- a/apps/server/src/services/threads/thread-send.ts +++ b/apps/server/src/services/threads/thread-send.ts @@ -1,3 +1,7 @@ +import { + acceptThreadSubmission, + type ThreadSubmissionReceipt, +} from "./thread-submission-receipts.js"; import { getEnvironment, getThread, @@ -86,6 +90,7 @@ type SendThreadMessagePayload = SendMessageRequest & { }; interface SendThreadMessageArgs { + submission?: ThreadSubmissionReceipt; beforeAppendInTransaction?: SendThreadMessageTransactionPreflight; /** * Present only when this send re-submits a failed turn. Marks the turn event @@ -142,6 +147,7 @@ interface SendThreadMessageQueueRequest { } interface AppendAndQueueSendThreadMessageArgs { + submission?: ThreadSubmissionReceipt; /** Retry provenance; absent for an original dispatch. */ retryOf?: TurnRequestRetryMarker; beforeAppendInTransaction?: SendThreadMessageTransactionPreflight; @@ -393,6 +399,7 @@ export function captureUserMessageSentTelemetry( } function appendAndQueueSendThreadMessageInTransaction({ + submission, retryOf, beforeAppendInTransaction, db, @@ -409,44 +416,50 @@ function appendAndQueueSendThreadMessageInTransaction({ }: AppendAndQueueSendThreadMessageArgs): AppendAndQueueSendThreadMessageResult { let activeThread: Thread | null = null; const request = db.transaction( - (tx) => { - beforeAppendInTransaction?.({ tx }); - const appended = - appendPreparedClientTurnRequestedEventWithNotificationInTransaction( - tx, - { - threadId: thread.id, - environmentId, - type: "client/turn/requested", - ...(retryOf !== undefined ? { retryOf } : {}), - input, - ...(inputGroups !== undefined ? { inputGroups } : {}), - execution, - initiator, - senderThreadId, - requestMethod: "turn/start", - source: "tell", - target, - requestId, - }, - ); - recordAcceptedPromptHistoryEntry( - { db: tx }, - { - thread, - input, - initiator, - target, - requestSequence: appended.sequence, - }, - ); - const queueResult = queueInTransaction({ - requestEventSequence: appended.sequence, + (tx) => + acceptThreadSubmission( tx, - }); - activeThread = queueResult.activeThread; - return appended; - }, + submission, + () => { + beforeAppendInTransaction?.({ tx }); + const appended = + appendPreparedClientTurnRequestedEventWithNotificationInTransaction( + tx, + { + threadId: thread.id, + environmentId, + type: "client/turn/requested", + ...(retryOf !== undefined ? { retryOf } : {}), + input, + ...(inputGroups !== undefined ? { inputGroups } : {}), + execution, + initiator, + senderThreadId, + requestMethod: "turn/start", + source: "tell", + target, + requestId, + }, + ); + recordAcceptedPromptHistoryEntry( + { db: tx }, + { + thread, + input, + initiator, + target, + requestSequence: appended.sequence, + }, + ); + const queueResult = queueInTransaction({ + requestEventSequence: appended.sequence, + tx, + }); + activeThread = queueResult.activeThread; + return appended; + }, + (request) => ({ delivery: "sent", turnRequestId: request.requestId }), + ), { behavior: "immediate" }, ); return { @@ -582,6 +595,7 @@ async function sendThreadMessageWithoutContextClear( if ( await dispatchTurnDuringReprovision({ + submission: args.submission, beforeRequestAppendInTransaction: beforeAppendInTransaction, deps, environment, @@ -658,6 +672,7 @@ async function sendThreadMessageWithoutContextClear( } : await prepareReadyThreadTurnCommand(deps, commandArgs); const queuedRequest = appendAndQueueSendThreadMessageInTransaction({ + submission: args.submission, ...(args.retryOf !== undefined ? { retryOf: args.retryOf } : {}), beforeAppendInTransaction: ({ tx }) => { beforeAppendInTransaction({ tx }); @@ -754,6 +769,7 @@ async function sendThreadMessageWithoutContextClear( requestId, }); const queuedRequest = appendAndQueueSendThreadMessageInTransaction({ + submission: args.submission, ...(args.retryOf !== undefined ? { retryOf: args.retryOf } : {}), beforeAppendInTransaction, db: deps.db, diff --git a/apps/server/src/services/threads/thread-submission-receipts.ts b/apps/server/src/services/threads/thread-submission-receipts.ts new file mode 100644 index 00000000000..f097ede946a --- /dev/null +++ b/apps/server/src/services/threads/thread-submission-receipts.ts @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { + threadSubmissionReceipts, + type DbConnection, + type DbTransaction, +} from "@bb/db"; +import { + clientTurnRequestIdSchema, + jsonValueSchema, + threadQueuedMessageSchema, + type JsonValue, + type PromptInput, +} from "@bb/domain"; +import type { + CreateQueuedMessageRequest, + SendMessageRequest, + SendMessageResponse, +} from "@bb/server-contract"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { ApiError } from "../../errors.js"; + +const acceptedSubmissionSchema = z.discriminatedUnion("delivery", [ + z.object({ + delivery: z.literal("sent"), + turnRequestId: clientTurnRequestIdSchema, + }), + z.object({ + delivery: z.literal("queued"), + queuedMessage: threadQueuedMessageSchema.omit({ + content: true, + replayed: true, + }), + }), +]); + +type AcceptedSubmission = z.infer; + +export interface ThreadSubmissionReceipt { + id: string; + accept( + tx: DbTransaction, + admit: () => T, + result: (value: T) => AcceptedSubmission, + ): T; +} + +interface PendingSubmission { + fingerprint: string; + promise: Promise; +} + +const pendingSubmissions = new WeakMap< + DbConnection, + Map +>(); + +function canonicalJson(value: JsonValue): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key]!)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function submissionConflict(): ApiError { + return new ApiError( + 409, + "client_submission_conflict", + "This submission ID was already used for a different message", + ); +} + +export function requireOrdinarySubmission( + input: PromptInput[], + mode?: SendMessageRequest["mode"], +): void { + if ( + (mode !== undefined && mode !== "start" && mode !== "queue-if-active") || + input.some( + (block) => + block.type === "text" && + block.mentions.some( + (mention) => + mention.resource.kind === "command" && + mention.resource.source === "command", + ), + ) + ) { + throw new ApiError( + 400, + "client_submission_unsupported", + "Durable delivery is only available for ordinary messages and queued follow-ups", + ); + } +} + +export function acceptThreadSubmission( + tx: DbTransaction, + receipt: ThreadSubmissionReceipt | undefined, + admit: () => T, + result: (value: T) => AcceptedSubmission, +): T { + return receipt === undefined ? admit() : receipt.accept(tx, admit, result); +} + +function markReplayed(response: SendMessageResponse): SendMessageResponse { + return response.delivery === "sent" + ? { ...response, replayed: true } + : { + ...response, + replayed: true, + queuedMessage: { ...response.queuedMessage, replayed: true }, + }; +} + +export async function withThreadSubmissionReceipt( + db: DbConnection, + args: { + threadId: string; + operation: "send" | "queue"; + payload: SendMessageRequest | CreateQueuedMessageRequest; + }, + submit: ( + receipt: ThreadSubmissionReceipt | undefined, + ) => Promise, +): Promise { + const clientSubmissionId = args.payload.clientSubmissionId; + if (clientSubmissionId === undefined) return submit(undefined); + const requestJson = JSON.stringify(args.payload); + const fingerprint = createHash("sha256") + .update(canonicalJson(jsonValueSchema.parse(JSON.parse(requestJson)))) + .digest("hex"); + const where = and( + eq(threadSubmissionReceipts.threadId, args.threadId), + eq(threadSubmissionReceipts.clientSubmissionId, clientSubmissionId), + ); + const readAccepted = (): SendMessageResponse | null => { + const row = db.select().from(threadSubmissionReceipts).where(where).get(); + if (row === undefined) return null; + if (row.fingerprint !== fingerprint || row.operation !== args.operation) + throw submissionConflict(); + if (row.result === null) + throw new ApiError( + 500, + "internal_error", + "Submission receipt was not completed", + ); + const result = acceptedSubmissionSchema.parse(JSON.parse(row.result)); + if (result.delivery === "sent") + return { + ok: true, + delivery: "sent", + turnRequestId: result.turnRequestId, + clientSubmissionId, + }; + return { + ok: true, + delivery: "queued", + clientSubmissionId, + queuedMessage: { + ...result.queuedMessage, + content: args.payload.input, + clientSubmissionId, + }, + }; + }; + const accepted = readAccepted(); + if (accepted !== null) return markReplayed(accepted); + const pending = + pendingSubmissions.get(db) ?? new Map(); + pendingSubmissions.set(db, pending); + const key = JSON.stringify([args.threadId, clientSubmissionId]); + const operationFingerprint = `${args.operation}:${fingerprint}`; + const existing = pending.get(key); + if (existing !== undefined) { + if (existing.fingerprint !== operationFingerprint) + throw submissionConflict(); + return existing.promise.then(markReplayed); + } + const receipt: ThreadSubmissionReceipt = { + id: clientSubmissionId, + accept(tx, admit, result) { + const inserted = tx + .insert(threadSubmissionReceipts) + .values({ + threadId: args.threadId, + clientSubmissionId, + operation: args.operation, + fingerprint, + result: null, + createdAt: Date.now(), + }) + .onConflictDoNothing() + .returning() + .get(); + if (inserted === undefined) + throw new ApiError( + 409, + "client_submission_in_progress", + "Submission is already being accepted", + ); + const value = admit(); + const acceptance = acceptedSubmissionSchema.parse(result(value)); + tx.update(threadSubmissionReceipts) + .set({ result: JSON.stringify(acceptance) }) + .where(where) + .run(); + return value; + }, + }; + const promise = Promise.resolve().then(async () => { + try { + await submit(receipt); + const acceptance = readAccepted(); + if (acceptance === null) + throw new ApiError( + 500, + "internal_error", + "Message was not durably accepted", + ); + return acceptance; + } catch (error) { + const acceptance = readAccepted(); + if (acceptance !== null) return markReplayed(acceptance); + if (error instanceof ApiError) { + const details = error.body.details; + if ( + details === undefined || + (details !== null && + typeof details === "object" && + !Array.isArray(details)) + ) { + error.body.details = { + ...details, + submission: { clientSubmissionId, acceptance: "rejected" }, + }; + } + } + throw error; + } finally { + pending.delete(key); + } + }); + pending.set(key, { fingerprint: operationFingerprint, promise }); + return promise; +} diff --git a/apps/server/src/services/threads/thread-turn-dispatch.ts b/apps/server/src/services/threads/thread-turn-dispatch.ts index 6fd10dc7d91..1c084216636 100644 --- a/apps/server/src/services/threads/thread-turn-dispatch.ts +++ b/apps/server/src/services/threads/thread-turn-dispatch.ts @@ -1,3 +1,4 @@ +import type { ThreadSubmissionReceipt } from "./thread-submission-receipts.js"; import type { PromptInput, ResolvedThreadExecutionOptions, @@ -26,6 +27,7 @@ export interface ReadyThreadEnvironment extends EnvironmentRow { } interface DispatchTurnDuringReprovisionArgs { + submission?: ThreadSubmissionReceipt; beforeRequestAppendInTransaction?: (args: { tx: DbTransaction }) => void; deps: LoggedPendingInteractionWorkSessionDeps; environment: EnvironmentRow; @@ -69,6 +71,7 @@ export async function dispatchTurnDuringReprovision( args.environment.status !== "provisioning" ) { requestThreadTargetReprovision(args.deps, { + submission: args.submission, beforeRequestAppendInTransaction: ({ tx }) => { args.beforeRequestAppendInTransaction?.({ tx }); const prepared = applyLoggedThreadLifecycleEventInTransaction( diff --git a/apps/server/test/system/general-settings.test.ts b/apps/server/test/system/general-settings.test.ts index 89da44f1e52..cbefe6c5c6a 100644 --- a/apps/server/test/system/general-settings.test.ts +++ b/apps/server/test/system/general-settings.test.ts @@ -16,6 +16,7 @@ describe("general settings", () => { showUnhandledProviderEvents: false, }); expect(body.primaryHostId).toBeNull(); + expect(body.featureFlags.durableMessageDelivery).toBe(true); }); }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index 4467fec0471..8867fd6dee9 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -310,6 +310,47 @@ describe("message.dispatch hook context", () => { }); describe("pending admission races", () => { + it("records first admission before replaying a saved submission", async () => { + await withTestHarness(async (harness) => { + const registry = emptyRegistry(); + registry["message.dispatch"].push({ + pluginId: "limiter", + handler: () => ({ action: "wait", reason: "at capacity" }) as const, + }); + installHooks(registry); + const { host, project } = seedDispatchFixture( + harness, + "host-submission-admission", + ); + const created = await createHookedThread(harness, { + hostId: host.id, + projectId: project.id, + }); + setPluginHookProvider(undefined); + const thread = getThread(harness.db, created.id); + if (!thread) throw new Error("Expected pending thread"); + const payload = { + clientSubmissionId: "pending_nanoid", + input: textInput("admit once"), + mode: "start" as const, + }; + const first = await acceptThreadSendRequest(harness.deps, { + thread, + payload, + }); + expect(first).toMatchObject({ + delivery: "sent", + clientSubmissionId: payload.clientSubmissionId, + turnRequestId: expect.any(String), + }); + const requests = turnRequests(harness, thread.id); + await expect( + acceptThreadSendRequest(harness.deps, { thread, payload }), + ).resolves.toEqual({ ...first, replayed: true }); + expect(turnRequests(harness, thread.id)).toEqual(requests); + }); + }); + it("re-decides a first message that lost the admission instead of calling it sent", async () => { await withTestHarness(async (harness) => { // Park the thread in `pending` with its first message queued, then take diff --git a/apps/server/test/threads/thread-submission-receipts.test.ts b/apps/server/test/threads/thread-submission-receipts.test.ts new file mode 100644 index 00000000000..2cfde78dc2d --- /dev/null +++ b/apps/server/test/threads/thread-submission-receipts.test.ts @@ -0,0 +1,558 @@ +import { queueInputForStartingTurn } from "../../src/services/threads/thread-turn-starting.js"; +import { buildExecutionOptions } from "../../src/services/threads/thread-commands.js"; +import { createThreadFromRequest } from "../../src/services/threads/thread-create.js"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ApiError } from "../../src/errors.js"; +import { withThreadSubmissionReceipt } from "../../src/services/threads/thread-submission-receipts.js"; +import { + archiveThread, + createConnection, + deleteQueuedThreadMessage, + getThread, + listEvents, + listQueuedThreadMessages, + threadSubmissionReceipts, +} from "@bb/db"; +import { + createStandaloneBuiltinCompactCommandInput, + defaultFeatureFlags, + featureFlagsSchema, + threadScope, +} from "@bb/domain"; +import { sendMessageResponseSchema } from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { acceptThreadSendRequest } from "../../src/services/threads/thread-send-request.js"; +import { createQueuedMessageForThread } from "../../src/services/threads/queued-messages.js"; +import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; +import { textInput } from "../helpers/prompt-input.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, + seedThreadRuntimeState, + seedEvent, +} from "../helpers/seed.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; + +function fixture( + harness: TestAppHarness, + status: "idle" | "active" | "error" = "idle", +) { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + path: "/tmp/submission-receipts", + status: "ready", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status, + }); + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId: "provider-submission", + threadId: thread.id, + }); + return { thread, environment }; +} + +function requestCount(harness: Pick, threadId: string) { + return listEvents(harness.db, { threadId }).filter( + (event) => event.type === "client/turn/requested", + ).length; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("durable submission acceptance", () => { + const clientSubmissionId = "message_nanoid-123"; + + it("coalesces concurrent sends and replays the original turn after reopen and archive", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness); + const before = requestCount(harness, thread.id); + const payload = { + clientSubmissionId, + input: textInput("send once"), + mode: "start" as const, + }; + const [first, duplicate] = await Promise.all([ + acceptThreadSendRequest(harness.deps, { thread, payload }), + acceptThreadSendRequest(harness.deps, { thread, payload }), + ]); + expect(first).toMatchObject({ + ok: true, + delivery: "sent", + clientSubmissionId, + turnRequestId: expect.any(String), + }); + expect(duplicate).toEqual({ ...first, replayed: true }); + expect(requestCount(harness, thread.id)).toBe(before + 1); + archiveThread(harness.db, harness.hub, thread.id); + const reopened = createConnection(harness.db.$client.serialize()); + try { + const archived = getThread(reopened, thread.id); + if (!archived) throw new Error("Expected archived thread"); + await expect( + acceptThreadSendRequest( + { ...harness.deps, db: reopened }, + { thread: archived, payload }, + ), + ).resolves.toEqual({ ...first, replayed: true }); + expect(requestCount({ db: reopened }, thread.id)).toBe(before + 1); + } finally { + reopened.$client.close(); + } + }); + }); + + it("keeps compatible queue replay metadata after consumption without storing the content twice", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const payload = { + clientSubmissionId, + input: textInput("queue once"), + serviceTier: "default" as const, + }; + const [first, duplicate] = await Promise.all([ + createQueuedMessageForThread(harness.deps, { thread, payload }), + createQueuedMessageForThread(harness.deps, { thread, payload }), + ]); + expect(first.clientSubmissionId).toBe(clientSubmissionId); + expect(duplicate).toEqual({ ...first, replayed: true }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + const receipt = harness.db.select().from(threadSubmissionReceipts).get(); + expect(receipt?.result).not.toContain("queue once"); + deleteQueuedThreadMessage(harness.db, harness.hub, first.id); + const reopened = createConnection(harness.db.$client.serialize()); + try { + const replay = await createQueuedMessageForThread( + { ...harness.deps, db: reopened }, + { thread, payload }, + ); + expect(replay).toEqual({ ...first, replayed: true }); + expect( + sendMessageResponseSchema.parse({ + ok: true, + delivery: "queued", + clientSubmissionId, + replayed: true, + queuedMessage: replay, + }), + ).toMatchObject({ + queuedMessage: { + content: payload.input, + model: first.model, + createdAt: first.createdAt, + }, + }); + expect(listQueuedThreadMessages(reopened, thread.id)).toHaveLength(0); + } finally { + reopened.$client.close(); + } + }); + }); + + it("uses database uniqueness when concurrent attempts have different connections", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const path = join( + harness.config.dataDir, + "submission-concurrency.sqlite", + ); + writeFileSync(path, harness.db.$client.serialize()); + const firstDb = createConnection(path); + const secondDb = createConnection(path); + const payload = { + clientSubmissionId, + input: textInput("one database admission"), + }; + try { + const results = await Promise.all([ + createQueuedMessageForThread( + { ...harness.deps, db: firstDb }, + { thread, payload }, + ), + createQueuedMessageForThread( + { ...harness.deps, db: secondDb }, + { thread, payload }, + ), + ]); + expect(results[0]?.id).toBe(results[1]?.id); + expect(listQueuedThreadMessages(firstDb, thread.id)).toHaveLength(1); + expect( + firstDb.select().from(threadSubmissionReceipts).all(), + ).toHaveLength(1); + } finally { + firstDb.$client.close(); + secondDb.$client.close(); + } + }); + }); + + it("marks only a settled unaccepted API failure while preserving its details", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness); + const error = new ApiError(400, "invalid_request", "Bad content", { + details: { field: "input" }, + }); + await expect( + withThreadSubmissionReceipt( + harness.db, + { + threadId: thread.id, + operation: "send", + payload: { + clientSubmissionId, + input: textInput("rejected"), + mode: "start", + }, + }, + async () => { + throw error; + }, + ), + ).rejects.toMatchObject({ + status: 400, + body: { + code: "invalid_request", + details: { + field: "input", + submission: { clientSubmissionId, acceptance: "rejected" }, + }, + }, + }); + expect( + harness.db.select().from(threadSubmissionReceipts).all(), + ).toHaveLength(0); + }); + }); + + it("records turn-starting queue acceptance in the same transaction", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const payload = { + clientSubmissionId, + input: textInput("wait for startup"), + mode: "queue-if-active" as const, + }; + const execution = await buildExecutionOptions( + harness.deps, + {}, + { threadId: thread.id }, + ); + const submit = () => + withThreadSubmissionReceipt( + harness.db, + { threadId: thread.id, operation: "send", payload }, + async (submission) => { + const outcome = queueInputForStartingTurn(harness.deps, { + claimed: null, + threadId: thread.id, + input: { + submission, + input: payload.input, + execution, + senderThreadId: null, + payload: { kind: "inline" }, + systemNotice: null, + }, + }); + if (outcome.kind !== "queued") + throw new Error("Expected starting queue"); + return { + ok: true, + delivery: "queued", + queuedMessage: outcome.entry, + }; + }, + ); + const first = await submit(); + expect(first).toMatchObject({ + delivery: "queued", + queuedMessage: { + waitingOn: { kind: "turn-starting" }, + clientSubmissionId, + }, + }); + expect(await submit()).toMatchObject({ replayed: true }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + }); + }); + + it("rejects changed payloads both during an attempt and after acceptance", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const payload = { clientSubmissionId, input: textInput("immutable") }; + const pending = createQueuedMessageForThread(harness.deps, { + thread, + payload, + }); + const changed = () => + createQueuedMessageForThread(harness.deps, { + thread, + payload: { ...payload, input: textInput("changed") }, + }); + await expect(changed()).rejects.toMatchObject({ + status: 409, + body: { code: "client_submission_conflict" }, + }); + await pending; + await expect(changed()).rejects.toMatchObject({ + status: 409, + body: { code: "client_submission_conflict" }, + }); + await expect( + acceptThreadSendRequest(harness.deps, { + thread, + payload: { ...payload, mode: "start" }, + }), + ).rejects.toMatchObject({ + status: 409, + body: { code: "client_submission_conflict" }, + }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + }); + }); + + it.each(["send", "queue"] as const)( + "rolls back the %s receipt together with a failed admission", + async (operation) => { + await withTestHarness(async (harness) => { + const { thread } = fixture( + harness, + operation === "queue" ? "active" : "idle", + ); + const before = requestCount(harness, thread.id); + const payload = { + clientSubmissionId, + input: textInput("retry after rollback"), + }; + const submit = () => + operation === "send" + ? acceptThreadSendRequest(harness.deps, { + thread, + payload: { ...payload, mode: "start" }, + }) + : createQueuedMessageForThread(harness.deps, { thread, payload }); + const table = + operation === "send" ? "events" : "queued_thread_messages"; + harness.db.$client.exec( + `CREATE TRIGGER reject_submission BEFORE INSERT ON ${table} BEGIN SELECT RAISE(ABORT, 'admission failed'); END`, + ); + await expect(submit()).rejects.toThrow("admission failed"); + expect( + harness.db.select().from(threadSubmissionReceipts).all(), + ).toHaveLength(0); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); + expect(requestCount(harness, thread.id)).toBe(before); + harness.db.$client.exec("DROP TRIGGER reject_submission"); + await submit(); + expect( + harness.db.select().from(threadSubmissionReceipts).all(), + ).toHaveLength(1); + }); + }, + ); + + it("returns committed acceptance when notification throws after commit", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const payload = { + clientSubmissionId, + input: textInput("accepted before disconnect"), + }; + vi.spyOn(harness.hub, "notifyThread").mockImplementationOnce(() => { + throw new Error("connection closed"); + }); + const accepted = await createQueuedMessageForThread(harness.deps, { + thread, + payload, + }); + expect(accepted).toMatchObject({ clientSubmissionId, replayed: true }); + await expect( + createQueuedMessageForThread(harness.deps, { thread, payload }), + ).resolves.toEqual(accepted); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + }); + }); + + it("queues an ordinary idle send that reaches an active thread instead of steering", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const before = requestCount(harness, thread.id); + const payload = { + clientSubmissionId, + input: textInput("ordinary follow-up"), + mode: "start" as const, + }; + const first = await acceptThreadSendRequest(harness.deps, { + thread, + payload, + }); + expect(first).toMatchObject({ + delivery: "queued", + clientSubmissionId, + queuedMessage: { + clientSubmissionId, + waitingOn: { kind: "thread-busy" }, + }, + }); + await acceptThreadSendRequest(harness.deps, { thread, payload }); + expect(requestCount(harness, thread.id)).toBe(before); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + }); + }); + + it.each(["idle", "error"] as const)( + "wakes an accepted queued follow-up on an %s thread", + async (status) => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, status); + const before = requestCount(harness, thread.id); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { + clientSubmissionId, + input: textInput("wake this follow-up"), + }, + }); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(requestCount(harness, thread.id)).toBe(before + 1); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }, + ); + + it("wakes a queued submission on a never-started existing thread", async () => { + await withTestHarness(async (harness) => { + const { thread: existing, environment } = fixture(harness); + const created = await createThreadFromRequest(harness.deps, { + projectId: existing.projectId, + providerId: existing.providerId, + origin: "app", + input: textInput("scheduled original"), + sendAt: Date.now() + 60_000, + startedOnBehalfOf: null, + environment: { + type: "host", + hostId: environment.hostId, + workspace: { type: "unmanaged", path: "/tmp/submission-receipts" }, + }, + }); + const thread = getThread(harness.db, created.id); + if (!thread) throw new Error("Expected pending thread"); + expect(thread.status).toBe("pending"); + const queued = await createQueuedMessageForThread(harness.deps, { + thread, + payload: { + clientSubmissionId, + input: textInput("ready before scheduled original"), + }, + }); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(getThread(harness.db, thread.id)?.status).not.toBe("pending"); + expect( + listQueuedThreadMessages(harness.db, thread.id).some( + (row) => row.id === queued.id, + ), + ).toBe(false); + expect(requestCount(harness, thread.id)).toBe(1); + }); + }); + + it("preserves manual queue pause and rejects conversation commands before admission", async () => { + await withTestHarness(async (harness) => { + const { thread, environment } = fixture(harness); + const before = requestCount(harness, thread.id); + seedEvent(harness.deps, { + threadId: thread.id, + environmentId: environment.id, + sequence: 3, + type: "system/thread/interrupted", + scope: threadScope(), + data: { reason: "manual-stop" }, + }); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { clientSubmissionId, input: textInput("wait for resume") }, + }); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(requestCount(harness, thread.id)).toBe(before); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + await expect( + acceptThreadSendRequest(harness.deps, { + thread, + payload: { + clientSubmissionId: "command", + mode: "start", + input: createStandaloneBuiltinCompactCommandInput(), + }, + }), + ).rejects.toMatchObject({ + status: 400, + body: { code: "client_submission_unsupported" }, + }); + }); + }); + + it("does not infer durable support from older feature flags", () => { + expect( + featureFlagsSchema.parse(defaultFeatureFlags).durableMessageDelivery, + ).toBeUndefined(); + }); + + it("retains legacy calls and keeps identical deliberate submissions distinct", async () => { + await withTestHarness(async (harness) => { + const { thread } = fixture(harness, "active"); + const input = textInput("same text"); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { input }, + }); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { input }, + }); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { input, clientSubmissionId: "first" }, + }); + await createQueuedMessageForThread(harness.deps, { + thread, + payload: { input, clientSubmissionId: "second" }, + }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(4); + expect( + harness.db.select().from(threadSubmissionReceipts).all(), + ).toHaveLength(2); + await expect( + acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input, + clientSubmissionId: "steer", + mode: "steer-if-active", + }, + }), + ).rejects.toMatchObject({ + status: 400, + body: { code: "client_submission_unsupported" }, + }); + }); + }); +}); diff --git a/apps/server/test/threads/thread-turn-dispatch.test.ts b/apps/server/test/threads/thread-turn-dispatch.test.ts index cd874c2d630..0cc25db12e6 100644 --- a/apps/server/test/threads/thread-turn-dispatch.test.ts +++ b/apps/server/test/threads/thread-turn-dispatch.test.ts @@ -1,3 +1,4 @@ +import { withThreadSubmissionReceipt } from "../../src/services/threads/thread-submission-receipts.js"; import { getThread, listEvents } from "@bb/db"; import { afterEach, expect, it, vi } from "vitest"; import { dispatchTurnDuringReprovision } from "../../src/services/threads/thread-turn-dispatch.js"; @@ -74,3 +75,66 @@ it("leaves reprovision unstarted when dispatch admission rejects the request", a expect(notifyThread).not.toHaveBeenCalled(); }); }); + +it("replays the durable request accepted through environment reprovisioning", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + status: "error", + path: "/tmp/submission-reprovision", + environmentProviderId: "personal-workspace", + environmentProviderPluginId: "bb-plugin-environment-personal-workspace", + isGitRepo: false, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + const payload = { + clientSubmissionId: "reprovision_nanoid", + input: textInput("durable reprovision"), + mode: "start" as const, + }; + const execution = await buildExecutionOptions( + harness.deps, + {}, + { threadId: thread.id }, + ); + const submit = () => + withThreadSubmissionReceipt( + harness.db, + { threadId: thread.id, operation: "send", payload }, + async (submission) => { + await dispatchTurnDuringReprovision({ + submission, + deps: harness.deps, + environment, + execution, + initiator: "user", + input: payload.input, + senderThreadId: null, + thread, + }); + return { ok: true, delivery: "sent" }; + }, + ); + const accepted = await submit(); + expect(accepted).toMatchObject({ + delivery: "sent", + clientSubmissionId: payload.clientSubmissionId, + turnRequestId: expect.any(String), + }); + await expect(submit()).resolves.toEqual({ ...accepted, replayed: true }); + expect( + listEvents(harness.db, { threadId: thread.id }).filter( + (event) => event.type === "client/turn/requested", + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/db/drizzle/0126_glamorous_ultimates.sql b/packages/db/drizzle/0126_glamorous_ultimates.sql new file mode 100644 index 00000000000..6c4d83c4494 --- /dev/null +++ b/packages/db/drizzle/0126_glamorous_ultimates.sql @@ -0,0 +1,12 @@ +CREATE TABLE `thread_submission_receipts` ( + `thread_id` text NOT NULL, + `client_submission_id` text NOT NULL, + `operation` text NOT NULL, + `fingerprint` text NOT NULL, + `result` text, + `created_at` integer NOT NULL, + PRIMARY KEY(`thread_id`, `client_submission_id`), + FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `queued_thread_messages` ADD `client_submission_id` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0126_snapshot.json b/packages/db/drizzle/meta/0126_snapshot.json new file mode 100644 index 00000000000..f35ed42b331 --- /dev/null +++ b/packages/db/drizzle/meta/0126_snapshot.json @@ -0,0 +1,5054 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6b1ea1e7-d732-48e8-aeff-1aed3e5c4433", + "prevId": "a7db3457-1600-41c5-a249-ea8dffc32375", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_variables_global_name": { + "name": "environment_variables_global_name", + "columns": [ + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NULL" + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NOT NULL" + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_provider_identity_idx": { + "name": "events_provider_identity_idx", + "columns": [ + "provider_thread_id", + "created_at" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'thread/identity'" + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "isUnique": true, + "where": "\"hosts\".\"destroyed_at\" is null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_backfills": { + "name": "project_attachment_backfills", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_cursor": { + "name": "thread_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_cursor": { + "name": "input_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_id": { + "name": "input_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_sequence": { + "name": "input_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_attachment_backfills_project_id_projects_id_fk": { + "name": "project_attachment_backfills_project_id_projects_id_fk", + "tableFrom": "project_attachment_backfills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_threads": { + "name": "project_attachment_threads", + "columns": { + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_attachment_threads_thread_idx": { + "name": "project_attachment_threads_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_attachment_threads_attachment_id_project_attachments_id_fk": { + "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "project_attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_attachment_threads_attachment_id_thread_id_pk": { + "columns": [ + "attachment_id", + "thread_id" + ], + "name": "project_attachment_threads_attachment_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachments": { + "name": "project_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ready_at": { + "name": "ready_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletion_claimed_at": { + "name": "deletion_claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_attachments_project_path_idx": { + "name": "project_attachments_project_path_idx", + "columns": [ + "project_id", + "stored_path" + ], + "isUnique": true + }, + "project_attachments_project_created_idx": { + "name": "project_attachments_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "project_attachments_deletion_idx": { + "name": "project_attachments_deletion_idx", + "columns": [ + "project_id", + "deletion_claimed_at", + "id" + ], + "isUnique": false, + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL" + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_attachments_size_check": { + "name": "project_attachments_size_check", + "value": "\"project_attachments\".\"size_bytes\" >= 0" + } + } + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_model_catalogs": { + "name": "provider_model_catalogs", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_only_models_json": { + "name": "selected_only_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_model_catalogs_host_id_hosts_id_fk": { + "name": "provider_model_catalogs_host_id_hosts_id_fk", + "tableFrom": "provider_model_catalogs", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_model_catalogs_host_id_provider_id_scope_key_pk": { + "columns": [ + "host_id", + "provider_id", + "scope_key" + ], + "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_submission_id": { + "name": "client_submission_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_plugin_metadata": { + "name": "thread_plugin_metadata", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_plugin_metadata_thread_id_threads_id_fk": { + "name": "thread_plugin_metadata_thread_id_threads_id_fk", + "tableFrom": "thread_plugin_metadata", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_plugin_metadata_thread_id_plugin_id_pk": { + "columns": [ + "thread_id", + "plugin_id" + ], + "name": "thread_plugin_metadata_thread_id_plugin_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_pruning_cursors": { + "name": "thread_pruning_cursors", + "columns": { + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_thread_id": { + "name": "last_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "current_thread_id": { + "name": "current_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upper_sequence": { + "name": "upper_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cycle": { + "name": "cycle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_root_sequence": { + "name": "latest_root_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_context_sequence": { + "name": "latest_context_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_event_id": { + "name": "probe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_phase": { + "name": "probe_phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_sequence": { + "name": "probe_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_witness_id": { + "name": "probe_witness_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_pruning_cursors_thread_idx": { + "name": "thread_pruning_cursors_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_pruning_cursors_thread_id_threads_id_fk": { + "name": "thread_pruning_cursors_thread_id_threads_id_fk", + "tableFrom": "thread_pruning_cursors", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_pruning_cursors_policy_scope_pk": { + "columns": [ + "policy", + "scope" + ], + "name": "thread_pruning_cursors_policy_scope_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "thread_pruning_cursors_scope_check": { + "name": "thread_pruning_cursors_scope_check", + "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" + } + } + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_submission_receipts": { + "name": "thread_submission_receipts", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_submission_id": { + "name": "client_submission_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_submission_receipts_thread_id_threads_id_fk": { + "name": "thread_submission_receipts_thread_id_threads_id_fk", + "tableFrom": "thread_submission_receipts", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_submission_receipts_thread_id_client_submission_id_pk": { + "columns": [ + "thread_id", + "client_submission_id" + ], + "name": "thread_submission_receipts_thread_id_client_submission_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_owner_thread_id": { + "name": "lifecycle_owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_deleted_at": { + "name": "storage_deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_id_idx": { + "name": "threads_project_id_idx", + "columns": [ + "project_id", + "id" + ], + "isUnique": false + }, + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_lifecycle_owner_idx": { + "name": "threads_lifecycle_owner_idx", + "columns": [ + "lifecycle_owner_thread_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index b04900ede00..bc22d9a6d08 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -883,6 +883,13 @@ "when": 1789588549270, "tag": "0125_silent_guardian", "breakpoints": true + }, + { + "idx": 126, + "version": "6", + "when": 1789707214283, + "tag": "0126_glamorous_ultimates", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 9c20cef51f3..35b4c71c2cd 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -52,6 +52,7 @@ import { createOrderKeyAfter, createOrderKeyBetween } from "./order-keys.js"; import { queryInSqliteVariableBatches } from "./events.js"; export interface CreateQueuedThreadMessageInput { + clientSubmissionId?: string; threadId: string; content: PromptInput[]; senderThreadId?: string | null; @@ -603,6 +604,7 @@ export function createQueuedThreadMessageInTransaction( .values({ id, threadId: input.threadId, + clientSubmissionId: input.clientSubmissionId ?? null, content: JSON.stringify(input.content), senderThreadId: input.senderThreadId ?? null, model: input.model, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1b90727a579..3220c00a32a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -952,10 +952,28 @@ export const promptHistoryEntries = sqliteTable( ], ); +export const threadSubmissionReceipts = sqliteTable( + "thread_submission_receipts", + { + threadId: text("thread_id") + .notNull() + .references(() => threads.id, { onDelete: "cascade" }), + clientSubmissionId: text("client_submission_id").notNull(), + operation: text("operation").$type<"send" | "queue">().notNull(), + fingerprint: text("fingerprint").notNull(), + result: text("result"), + createdAt: integer("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.threadId, table.clientSubmissionId] }), + ], +); + export const queuedThreadMessages = sqliteTable( "queued_thread_messages", { id: text("id").primaryKey(), + clientSubmissionId: text("client_submission_id"), // JSON `{ kind, subject }` when this row is one of core's own system // notices rather than somebody's message; NULL for every ordinary row. // Owned by the server, which is the only thing that writes or reads it. diff --git a/packages/domain/src/feature-flags.ts b/packages/domain/src/feature-flags.ts index ae8bf32142d..89f003bace4 100644 --- a/packages/domain/src/feature-flags.ts +++ b/packages/domain/src/feature-flags.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const featureFlagsSchema = z.object({ placeholder: z.boolean(), + durableMessageDelivery: z.boolean().optional(), timelineWindowEventBudget: z.number().int().positive(), }); export type FeatureFlags = z.infer; diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 8dc9b3a0dc7..337ebfb8c06 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -334,7 +334,11 @@ export const threadPullRequestSchema = z .strict(); export type ThreadPullRequest = z.infer; +export const clientSubmissionIdSchema = z.string().min(1).max(128); + export const threadQueuedMessageSchema = z.object({ + clientSubmissionId: clientSubmissionIdSchema.optional(), + replayed: z.boolean().optional(), id: z.string(), initiator: z.enum(["user", "agent", "system"]), senderThreadId: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 292420f4382..34dd9b9d4dc 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -264,6 +264,7 @@ export interface ThreadDeleteArgs extends DeleteThreadRequest { } export interface ThreadSendArgs extends SendMessageRequest { + signal?: AbortSignal; threadId: string; } @@ -312,6 +313,7 @@ export interface ThreadQueuedMessageArgs { } export interface ThreadQueuedMessageCreateArgs extends CreateQueuedMessageRequest { + signal?: AbortSignal; threadId: string; } @@ -673,6 +675,7 @@ function updateJson(args: ThreadUpdateArgs): UpdateThreadRequest { function sendJson(args: ThreadSendArgs): SendMessageRequest { return { + clientSubmissionId: args.clientSubmissionId, input: args.input, mode: args.mode, model: args.model, @@ -965,12 +968,15 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }; const queuedMessages: ThreadQueuedMessagesArea = { async create(input) { - const { threadId, ...json } = input; + const { threadId, signal, ...json } = input; return transport.readJson( - transport.api.v1.threads[":id"]["queued-messages"].$post({ - param: { id: threadId }, - json, - }), + transport.api.v1.threads[":id"]["queued-messages"].$post( + { + param: { id: threadId }, + json, + }, + ...signalRequestArgs(signal), + ), ); }, async delete(input) { @@ -1288,10 +1294,13 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }, async send(input) { return transport.readJson( - transport.api.v1.threads[":id"].send.$post({ - param: { id: input.threadId }, - json: sendJson(input), - }), + transport.api.v1.threads[":id"].send.$post( + { + param: { id: input.threadId }, + json: sendJson(input), + }, + ...signalRequestArgs(input.signal), + ), ); }, async retry(input) { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index e5fe470a80d..2df3f3b0061 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -296,6 +296,46 @@ describe("@bb/sdk", () => { ]); }); + it("forwards submission IDs and abort signals without serializing the signal", async () => { + const received: Array<{ + signal: AbortSignal | null | undefined; + body: string | undefined; + }> = []; + const fetch: FetchImplementation = async (_input, init) => { + received.push({ signal: init?.signal, body: bodyText(init) }); + return jsonResponse({ body: { ok: true, delivery: "sent" } }); + }; + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch, + runtime: "node", + }), + }); + const signal = new AbortController().signal; + const input = [{ type: "text" as const, text: "keep this", mentions: [] }]; + await sdk.threads.send({ + threadId: "thr_submission", + input, + mode: "start", + clientSubmissionId: "send_nanoid", + signal, + }); + await sdk.threads.queuedMessages.create({ + threadId: "thr_submission", + input, + clientSubmissionId: "queue_nanoid", + signal, + }); + expect(received.map((request) => request.signal)).toEqual([signal, signal]); + expect(received.map((request) => JSON.parse(request.body ?? "{}"))).toEqual( + [ + { input, mode: "start", clientSubmissionId: "send_nanoid" }, + { input, clientSubmissionId: "queue_nanoid" }, + ], + ); + }); + it("forwards read abort signals to fetch", async () => { const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined; diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index a0c7bc44e35..26a05a37049 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -22,6 +22,7 @@ import { threadOriginKindSchema, threadListEntrySchema, threadQueuedMessageSchema, + clientSubmissionIdSchema, threadSearchSourceKindSchema, threadStatusSchema, threadTimelineActivePromptModeSchema, @@ -259,7 +260,9 @@ const sendMessageRequestFieldsSchema = z.object({ sendAt: z.number().int().nonnegative().optional(), }); -export const sendMessageRequestSchema = sendMessageRequestFieldsSchema; +export const sendMessageRequestSchema = sendMessageRequestFieldsSchema.extend({ + clientSubmissionId: clientSubmissionIdSchema.optional(), +}); export type SendMessageRequest = z.infer; /** @@ -280,10 +283,18 @@ export type SendMessageDelivery = z.infer; * for now" would invite every caller to check fields that cannot exist. */ export const sendMessageResponseSchema = z.discriminatedUnion("delivery", [ - z.object({ ok: z.literal(true), delivery: z.literal("sent") }), + z.object({ + ok: z.literal(true), + delivery: z.literal("sent"), + clientSubmissionId: clientSubmissionIdSchema.optional(), + replayed: z.boolean().optional(), + turnRequestId: clientTurnRequestIdSchema.optional(), + }), z.object({ ok: z.literal(true), delivery: z.literal("queued"), + clientSubmissionId: clientSubmissionIdSchema.optional(), + replayed: z.boolean().optional(), queuedMessage: threadQueuedMessageSchema, }), ]); @@ -370,6 +381,7 @@ export const sendQueuedMessageModeSchema = z.enum(["auto", "steer"]); export type SendQueuedMessageMode = z.infer; export const createQueuedMessageRequestSchema = z.object({ + clientSubmissionId: clientSubmissionIdSchema.optional(), input: z.array(promptInputSchema).min(1), model: z.string().optional(), serviceTier: serviceTierSchema.optional(), diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 861a2f5281f..18195d9dfdd 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -54,8 +54,15 @@ const OPTIONAL_SERVER_FIELD_GROUP_LIMIT = 31; const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ { reason: - "Timeline snapshot fields are absent on older servers; content metadata and detail continuation inputs only apply to paginated content.", + "Timeline and submission correlation fields are absent on older servers or legacy requests; content metadata and detail continuation inputs only apply to paginated content.", fields: [ + "createQueuedMessageRequestSchema.clientSubmissionId", + "sendMessageRequestSchema.clientSubmissionId", + "sendQueuedMessageResponseSchema.clientSubmissionId", + "sendQueuedMessageResponseSchema.replayed", + "sendQueuedMessageResponseSchema.turnRequestId", + "sendQueuedMessageResponseSchema.queuedMessage.clientSubmissionId", + "sendQueuedMessageResponseSchema.queuedMessage.replayed", "threadTimelineResponseSchema.timelinePage.contentPage", "threadTimelineResponseSchema.timelinePage.historySnapshot", "threadTimelineResponseSchema.timelinePage.olderRowsSourceSeqEnd", @@ -870,6 +877,36 @@ describe("server-contract canonical schemas", () => { ).toMatchObject({ ownsPath: false }); }); + it("accepts optional bounded nanoid submission IDs and legacy response shapes", () => { + for (const schema of [ + sendMessageRequestSchema, + createQueuedMessageRequestSchema, + ]) { + const payload = { + input: [{ type: "text", text: "keep this" }], + mode: "start", + }; + expect(schema.parse(payload)).not.toHaveProperty("clientSubmissionId"); + expect( + schema.parse({ ...payload, clientSubmissionId: "nanoid_With-dashes" }), + ).toHaveProperty("clientSubmissionId", "nanoid_With-dashes"); + for (const clientSubmissionId of ["", "x".repeat(129)]) + expect(() => + schema.parse({ ...payload, clientSubmissionId }), + ).toThrow(); + } + expect( + contract.sendMessageResponseSchema.parse({ ok: true, delivery: "sent" }), + ).toEqual({ ok: true, delivery: "sent" }); + expect(() => + contract.editMessageRequestSchema.parse({ + operationId: "edit", + input: [{ type: "text", text: "edit" }], + clientSubmissionId: "not-supported", + }), + ).toThrow(); + }); + it("parses request contracts", () => { expect( createThreadRequestSchema.parse({ diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 4e9483fc6e3..953373c8fe4 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -246,6 +246,7 @@ Opening threads and files in the app: Messaging: bb thread tell Send a follow-up message + --submission-id Stable ID for retrying an ordinary follow-up --mode Message mode: steer (default), queue, or auto --model Model override for this turn --reasoning-level Reasoning level override @@ -267,6 +268,23 @@ Messaging: its `id`, `waitingOn`, and `sendAt`. A deferred message waits for a thread that failed while it was deferred, and delivers when the thread is retried. + --submission-id uses a stable 1–128 character ID for an ordinary message. + With it, tell defaults to queue mode: a delayed message stays a follow-up + instead of steering a later turn. Steering and conversation commands cannot + use submission IDs. Retrying the same ID and identical payload replays its + original acceptance, even after queue consumption, archiving, or restart; + changed content/options return a conflict. Reuse uploaded attachment paths + and an absolute --send-at timestamp across retries. + SDK callers pass clientSubmissionId and optional signal to threads.send or + threads.queuedMessages.create. Confirm system config featureFlags contains + durableMessageDelivery: true before automatic retries; absence means stop + replaying and retain the message. A replayed queued response is historical: + refresh queue state instead of inserting it as a current row. + A definitive error may include details.submission with the matching + clientSubmissionId and acceptance: rejected after the attempt settles; only + that marker resolves uncertainty from an earlier lost response. Ordinary + status/code alone does not prove that the earlier request was unaccepted. + --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude Code and Codex threads). Plain "/plan ..." text is not recognized; it reaches @@ -340,7 +358,7 @@ Interactions: Queued messages: bb thread queue list [] [--wait-holder plugin:] - bb thread queue create + bb thread queue create [--submission-id ] bb thread queue update [--file ] [--image ] bb thread queue send [--mode auto|steer] bb thread queue reorder [--after ] [--before ] diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 774043f029a..35f08297d59 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -12,6 +12,16 @@ ` when you need a shorter or longer budget. - Use `bb thread tell "..."` when requirements change, a blocker needs clarification, or follow-up work is needed. +- For connection-failure retries of ordinary messages, pass the same + `--submission-id ` (1–128 characters) to `bb thread tell` or + `bb thread queue create`, with identical content/options. Tell defaults to + queue mode when this flag is supplied; steering and conversation commands + are excluded. Preserve uploaded attachment paths and absolute scheduled + times across retries. SDK equivalents accept `clientSubmissionId` and + `signal`. Only retry automatically while system config advertises + `featureFlags.durableMessageDelivery: true`; otherwise retain and pause. + A `replayed` queued response describes historical acceptance, so refresh the + queue instead of inserting that response as a live row. - Add `--plan` to `bb thread spawn` or `bb thread tell` to send the prompt as the provider's structured `/plan` action: the agent proposes a plan for approval before executing when supported by the provider. Plain `/plan ...` text is diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5c369808ce..f55e4389db3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: cacache: specifier: ^20.0.4 version: 20.0.4 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 lightningcss: specifier: ^1.32.0 version: 1.32.0 @@ -12082,6 +12085,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -24873,6 +24880,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-copy@4.0.2: {} fast-deep-equal@3.1.3: {} From 2d9d4e59bd570c1381feac4c14ab2702ea461a72 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:13:56 -0400 Subject: [PATCH 08/29] Align durable delivery integration with cache and migration contracts --- .../thread-runtime-cache-owner.ts | 22 ++++++++++++ .../useDurableMessageSubmission.test.tsx | 22 +++++++++++- .../src/hooks/useDurableMessageSubmission.ts | 11 +++++- apps/app/src/hooks/usePromptDraftStorage.ts | 8 +++-- .../message-delivery/MessageDeliverySync.tsx | 34 ++++--------------- .../lib/message-delivery/coordinator.test.ts | 24 +++++++++++-- .../src/lib/message-delivery/store.test.ts | 12 +++---- ...ThreadDetailPromptArea.keystrokes.test.tsx | 18 ++++++++++ .../thread-submission-receipts.test.ts | 6 +++- .../test/threads/thread-turn-dispatch.test.ts | 7 +++- packages/db/test/migrate.test.ts | 11 ++++++ 11 files changed, 132 insertions(+), 43 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index a462ddb5a7d..e2e18ed330b 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -882,6 +882,28 @@ export function prefetchThreadQueuedMessages({ }); } +export async function refreshAcceptedSubmissionQueries({ + queryClient, + threadId, + load, +}: PrefetchThreadQueuedMessagesArgs): Promise { + await queryClient.fetchQuery({ + queryKey: threadQueuedMessagesQueryKey(threadId), + queryFn: ({ signal }) => load(signal), + staleTime: 0, + }); + await Promise.all([ + queryClient.refetchQueries( + { queryKey: threadTimelineQueryKeyPrefix(threadId), type: "active" }, + { throwOnError: true, cancelRefetch: false }, + ), + queryClient.refetchQueries( + { queryKey: threadPromptHistoryQueryKey(threadId), type: "active" }, + { throwOnError: true, cancelRefetch: false }, + ), + ]); +} + export function applyCreateThreadResult({ queryClient, request, diff --git a/apps/app/src/hooks/useDurableMessageSubmission.test.tsx b/apps/app/src/hooks/useDurableMessageSubmission.test.tsx index e5c1a6f598a..dcb0a12de31 100644 --- a/apps/app/src/hooks/useDurableMessageSubmission.test.tsx +++ b/apps/app/src/hooks/useDurableMessageSubmission.test.tsx @@ -103,7 +103,27 @@ describe("durable composer submission", () => { { kind: "queue", request: { - input: [{ type: "text", text: "/clear", mentions: [] }], + input: [ + { + type: "text", + text: "/clear", + mentions: [ + { + start: 0, + end: 6, + resource: { + kind: "command", + trigger: "/", + name: "clear", + source: "command", + origin: "builtin", + label: "clear", + argumentHint: null, + }, + }, + ], + }, + ], }, }, draft, diff --git a/apps/app/src/hooks/useDurableMessageSubmission.ts b/apps/app/src/hooks/useDurableMessageSubmission.ts index 9da597d1507..9b427aff4dd 100644 --- a/apps/app/src/hooks/useDurableMessageSubmission.ts +++ b/apps/app/src/hooks/useDurableMessageSubmission.ts @@ -21,7 +21,16 @@ type DurableRequest = export function isDurableMessageInput(input: readonly PromptInput[]): boolean { return ( !isStandaloneBuiltinClearCommand(input) && - !isStandaloneBuiltinCompactCommand(input) + !isStandaloneBuiltinCompactCommand(input) && + !input.some( + (block) => + block.type === "text" && + block.mentions.some( + (mention) => + mention.resource.kind === "command" && + mention.resource.source === "command", + ), + ) ); } diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index c54d0500b4f..d14ea2f9d4a 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -197,10 +197,12 @@ function writePromptDraft( ): void { if (!storageKey || typeof window === "undefined") return; + const cachedEntry = promptDraftCache.get(storageKey); const hadHandoff = - promptDraftCache.get(storageKey)?.submissionHandoffId !== undefined || - readSubmissionHandoffId(readStoredPromptDraftValue(storageKey)) !== - undefined; + (cachedEntry + ? cachedEntry.submissionHandoffId + : readSubmissionHandoffId(readStoredPromptDraftValue(storageKey))) !== + undefined; promptDraftCache.set(storageKey, { rawValue: null, draft: isPromptDraftEmpty(value) ? EMPTY_PROMPT_DRAFT : value, diff --git a/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx b/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx index cd1c9c597ae..8ff315cd2cd 100644 --- a/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx +++ b/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx @@ -3,15 +3,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; import { appToast } from "@/components/ui/app-toast"; import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { - systemConfigQueryKey, - threadPromptHistoryQueryKey, - threadQueuedMessagesQueryKey, - threadTimelineQueryKeyPrefix, -} from "@/hooks/queries/query-keys"; +import { invalidateSystemConfig } from "@/hooks/cache-owners/system-cache-effects"; import { applyQueuedMessageCreateResult, applySendThreadMessageSuccess, + refreshAcceptedSubmissionQueries, } from "@/hooks/cache-owners/thread-runtime-cache-owner"; import { BbHttpError, sdk } from "@/lib/sdk"; import { wsManager } from "@/lib/ws"; @@ -41,7 +37,7 @@ export function MessageDeliverySync() { ) => { if (expected === received) return; deliverySupported = false; - void queryClient.invalidateQueries({ queryKey: systemConfigQueryKey() }); + invalidateSystemConfig({ queryClient }); const error = new Error( "Message delivery paused: the server did not confirm the saved submission ID.", ); @@ -96,31 +92,15 @@ export function MessageDeliverySync() { transaction: undefined, }); } - await queryClient.fetchQuery({ - queryKey: threadQueuedMessagesQueryKey(entry.threadId), - queryFn: () => + await refreshAcceptedSubmissionQueries({ + queryClient, + threadId: entry.threadId, + load: () => sdk.threads.queuedMessages.list({ threadId: entry.threadId, signal, }), - staleTime: 0, }); - await Promise.all([ - queryClient.refetchQueries( - { - queryKey: threadTimelineQueryKeyPrefix(entry.threadId), - type: "active", - }, - { throwOnError: true, cancelRefetch: false }, - ), - queryClient.refetchQueries( - { - queryKey: threadPromptHistoryQueryKey(entry.threadId), - type: "active", - }, - { throwOnError: true, cancelRefetch: false }, - ), - ]); }, classifyFailure: (error, entry) => { const message = diff --git a/apps/app/src/lib/message-delivery/coordinator.test.ts b/apps/app/src/lib/message-delivery/coordinator.test.ts index 5713208d7d7..8753f13bdd5 100644 --- a/apps/app/src/lib/message-delivery/coordinator.test.ts +++ b/apps/app/src/lib/message-delivery/coordinator.test.ts @@ -111,14 +111,32 @@ it("aborts a hung request and retries its same ID before delivering later work", .fn() .mockImplementationOnce(() => new Promise(() => undefined)) .mockResolvedValue(accepted); - stop = coordinator.startMessageDelivery(options({ deliver })); + const classifyFailure = vi.fn( + (error) => ({ + kind: "unknown", + message: error instanceof Error ? error.message : "Disconnected", + }), + ); + stop = coordinator.startMessageDelivery( + options({ deliver, classifyFailure }), + ); await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)); const signal = deliver.mock.calls[0]?.[1]; await vi.advanceTimersByTimeAsync(30_000); + expect(signal?.aborted).toBe(true); + expect(signal?.reason).toMatchObject({ + name: "TimeoutError", + message: "Message delivery timed out", + }); await vi.waitFor(() => - expect(store.getSubmissions()[0]?.error).toBe("Message delivery timed out"), + expect(classifyFailure).toHaveBeenCalledWith( + expect.objectContaining({ + name: "TimeoutError", + message: "Message delivery timed out", + }), + expect.objectContaining({ clientSubmissionId: first.clientSubmissionId }), + ), ); - expect(signal?.aborted).toBe(true); await vi.advanceTimersByTimeAsync(1_100); await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); expect(deliver.mock.calls.map(([entry]) => entry.clientSubmissionId)).toEqual( diff --git a/apps/app/src/lib/message-delivery/store.test.ts b/apps/app/src/lib/message-delivery/store.test.ts index e4f0b2df78d..3d6df393a49 100644 --- a/apps/app/src/lib/message-delivery/store.test.ts +++ b/apps/app/src/lib/message-delivery/store.test.ts @@ -183,7 +183,7 @@ it("skips retained rejection and corrects it with a new ID at the delivery tail" threadId: first.threadId, id: first.id, expectedUpdatedAt: rejected.updatedAt, - input: [{ type: "text", text: "Corrected" }], + input: [{ type: "text", text: "Corrected", mentions: [] }], }); await store.refreshSubmissions(); const corrected = store @@ -208,19 +208,19 @@ it("pauses delivery while an inline edit owns the claim and fences an expired ed threadId: entry.threadId, id: entry.id, expectedUpdatedAt: entry.updatedAt, - input: [{ type: "text", text: "Updated" }], + input: [{ type: "text", text: "Updated", mentions: [] }], editToken: edit.token, }); const delivering = await claim("sender"); expect(delivering.entry.request.input).toEqual([ - { type: "text", text: "Updated" }, + { type: "text", text: "Updated", mentions: [] }, ]); await expect( store.editSubmission({ threadId: entry.threadId, id: entry.id, expectedUpdatedAt: entry.updatedAt, - input: [{ type: "text", text: "Stale edit" }], + input: [{ type: "text", text: "Stale edit", mentions: [] }], editToken: edit.token, }), ).rejects.toThrow("already being delivered"); @@ -285,7 +285,7 @@ it("fences an editor after expiry lets a delivery owner claim the saved payload" threadId: original.threadId, id: original.id, expectedUpdatedAt: original.updatedAt, - input: [{ type: "text", text: "Unsaved new wording" }], + input: [{ type: "text", text: "Unsaved new wording", mentions: [] }], editToken: edit.token, }), ).rejects.toThrow("already being delivered"); @@ -310,7 +310,7 @@ it("leaves a rejected original untouched when its correction transaction fails", threadId: original.threadId, id: original.id, expectedUpdatedAt: rejected.updatedAt, - input: [{ type: "text", text: "Corrected wording" }], + input: [{ type: "text", text: "Corrected wording", mentions: [] }], }), ).rejects.toThrow("Quota exceeded"); put.mockRestore(); diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index 5991fb5ece5..d62d372ca46 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -29,10 +29,28 @@ import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea"; const mocks = vi.hoisted(() => ({ sendMessageMutateAsync: vi.fn(), + durableEnqueue: vi.fn(), shellProbeRenders: vi.fn(), updateQueuedMessageMutateAsync: vi.fn(), })); +vi.mock("@/hooks/useDurableMessageSubmission", () => ({ + useDurableMessageSubmission: () => ({ + enabled: false, + connected: true, + isSaving: false, + enqueue: mocks.durableEnqueue, + }), + isDurableMessageInput: () => true, +})); + +vi.mock("@/hooks/useQueuedMessageRows", () => ({ + useQueuedMessageRows: ( + _threadId: string, + messages: readonly ThreadQueuedMessage[], + ) => messages, +})); + vi.mock("react-router-dom", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; diff --git a/apps/server/test/threads/thread-submission-receipts.test.ts b/apps/server/test/threads/thread-submission-receipts.test.ts index 2cfde78dc2d..58dadf41f21 100644 --- a/apps/server/test/threads/thread-submission-receipts.test.ts +++ b/apps/server/test/threads/thread-submission-receipts.test.ts @@ -345,7 +345,11 @@ describe("durable submission acceptance", () => { harness.db.$client.exec( `CREATE TRIGGER reject_submission BEFORE INSERT ON ${table} BEGIN SELECT RAISE(ABORT, 'admission failed'); END`, ); - await expect(submit()).rejects.toThrow("admission failed"); + await expect(submit()).rejects.toMatchObject( + operation === "send" + ? { cause: { message: "admission failed" } } + : { message: "admission failed" }, + ); expect( harness.db.select().from(threadSubmissionReceipts).all(), ).toHaveLength(0); diff --git a/apps/server/test/threads/thread-turn-dispatch.test.ts b/apps/server/test/threads/thread-turn-dispatch.test.ts index 0cc25db12e6..3a3a793555c 100644 --- a/apps/server/test/threads/thread-turn-dispatch.test.ts +++ b/apps/server/test/threads/thread-turn-dispatch.test.ts @@ -103,7 +103,12 @@ it("replays the durable request accepted through environment reprovisioning", as }; const execution = await buildExecutionOptions( harness.deps, - {}, + { + model: "gpt-5", + reasoningLevel: "medium", + serviceTier: "default", + permissionMode: "full", + }, { threadId: thread.id }, ); const submit = () => diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index abdab5b19a5..1b77ac0ae3d 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -854,6 +854,17 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } function rewindMachineProvidersMigration(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS thread_submission_receipts"); + if ( + db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)") + .all() + .some((column) => column.name === "client_submission_id") + ) { + db.$client.exec( + "ALTER TABLE queued_thread_messages DROP COLUMN client_submission_id", + ); + } db.$client.exec("DROP TABLE IF EXISTS thread_pruning_cursors"); db.$client.exec("DROP TABLE IF EXISTS project_attachment_threads"); db.$client.exec("DROP TABLE IF EXISTS project_attachments"); From a4171dff20d6ca30487723bec5a86f4cd1c36c64 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:19:45 -0400 Subject: [PATCH 09/29] Preserve delivery timeout reasons and version additive SDK fields --- apps/app/src/lib/message-delivery/coordinator.ts | 7 +++---- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/app/src/lib/message-delivery/coordinator.ts b/apps/app/src/lib/message-delivery/coordinator.ts index ceb5aed9764..8d3294b0f8e 100644 --- a/apps/app/src/lib/message-delivery/coordinator.ts +++ b/apps/app/src/lib/message-delivery/coordinator.ts @@ -101,15 +101,14 @@ export function startMessageDelivery( const controller = new AbortController(); controllers.add(controller); let leaseLost = false; - let rejectAbort: (error: Error) => void = () => undefined; + let rejectAbort: (error: unknown) => void = () => undefined; const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; }); const onAbort = () => rejectAbort( - controller.signal.reason instanceof Error - ? controller.signal.reason - : new DOMException("Message delivery interrupted", "AbortError"), + controller.signal.reason ?? + new DOMException("Message delivery interrupted", "AbortError"), ); controller.signal.addEventListener("abort", onAbort, { once: true }); const timeout = setTimeout( diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index d8eedd8d3ab..1935dfa91b6 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.99"; +export const PLUGIN_SDK_VERSION = "0.4.100"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 1b834c3593b..611481fe295 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.99", + "version": "0.4.100", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" From c57f44627a697fb944a30db432dd32cd31a00642 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:23:58 -0400 Subject: [PATCH 10/29] Load persistent message delivery outside the initial app bundle --- apps/app/src/App.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 514596d3e5e..4e4b11442c6 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -15,7 +15,6 @@ import { RouteNavigationIndicator } from "./components/ui/route-navigation-indic import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; -import { MessageDeliverySync } from "@/lib/message-delivery/MessageDeliverySync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -70,6 +69,11 @@ import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provi import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; +const MessageDeliverySync = lazy(() => + import("@/lib/message-delivery/MessageDeliverySync").then((m) => ({ + default: m.MessageDeliverySync, + })), +); const SettingsView = lazy(() => import("./views/SettingsView").then((m) => ({ default: m.SettingsView, @@ -434,7 +438,9 @@ export function App() { - + + + Date: Fri, 18 Sep 2026 02:02:38 -0400 Subject: [PATCH 11/29] Keep local delivery rows out of server sender metadata rendering --- .../promptbox/banner/QueuedMessagesList.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 5003ab4aa98..1baae4078ee 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -1781,10 +1781,14 @@ export function QueuedMessagesList({ Date: Fri, 18 Sep 2026 02:07:40 -0400 Subject: [PATCH 12/29] Load embedded delivery composer when a plugin opens its chat --- .../components/plugin/PluginThreadChat.tsx | 107 +++++++++++------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/apps/app/src/components/plugin/PluginThreadChat.tsx b/apps/app/src/components/plugin/PluginThreadChat.tsx index a61a3e578b5..9c22b217e57 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.tsx @@ -1,4 +1,11 @@ -import { useCallback, useContext, useMemo, type ReactNode } from "react"; +import { + lazy, + Suspense, + useCallback, + useContext, + useMemo, + type ReactNode, +} from "react"; import { useNavigate } from "react-router-dom"; import type { ThreadChatMessageAction, @@ -13,7 +20,7 @@ import { Skeleton } from "@bb/shared-ui/skeleton"; import { cn } from "@bb/shared-ui/lib/utils"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; -import { EmbeddedThreadChat } from "@/components/thread/embedded-chat"; + import { ThreadTimelinePanelContent, type ThreadTimelineConsumerMessageAction, @@ -39,6 +46,24 @@ import { getThreadRoutePath, } from "@/lib/route-paths"; +const EmbeddedThreadChat = lazy(() => + import("@/components/thread/embedded-chat/EmbeddedThreadChat").then( + (module) => ({ + default: module.EmbeddedThreadChat, + }), + ), +); + +function ThreadChatLoading() { + return ( +
+ + + +
+ ); +} + export function PluginThreadChat({ threadId, variant = "full", @@ -248,13 +273,7 @@ function PluginThreadChatBody({ ); } if (thread === undefined) { - return ( -
- - - -
- ); + return ; } if (variant === "timeline") { @@ -287,40 +306,42 @@ function PluginThreadChatBody({ return ( - + }> + + ); } From 740c6d031157bffc8813bf40b01f51fdb7e0e894 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 02:13:04 -0400 Subject: [PATCH 13/29] Keep composer loading stable and budget the small draft handoff addition --- apps/app/bundle-budget.json | 2 +- .../components/plugin/PluginThreadChat.tsx | 107 +++++++----------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index b464c7b6c98..af7e154aab0 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -47,7 +47,7 @@ "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1722912, + "maxBootBytes": 1727008, "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs", diff --git a/apps/app/src/components/plugin/PluginThreadChat.tsx b/apps/app/src/components/plugin/PluginThreadChat.tsx index 9c22b217e57..a61a3e578b5 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.tsx @@ -1,11 +1,4 @@ -import { - lazy, - Suspense, - useCallback, - useContext, - useMemo, - type ReactNode, -} from "react"; +import { useCallback, useContext, useMemo, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import type { ThreadChatMessageAction, @@ -20,7 +13,7 @@ import { Skeleton } from "@bb/shared-ui/skeleton"; import { cn } from "@bb/shared-ui/lib/utils"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; - +import { EmbeddedThreadChat } from "@/components/thread/embedded-chat"; import { ThreadTimelinePanelContent, type ThreadTimelineConsumerMessageAction, @@ -46,24 +39,6 @@ import { getThreadRoutePath, } from "@/lib/route-paths"; -const EmbeddedThreadChat = lazy(() => - import("@/components/thread/embedded-chat/EmbeddedThreadChat").then( - (module) => ({ - default: module.EmbeddedThreadChat, - }), - ), -); - -function ThreadChatLoading() { - return ( -
- - - -
- ); -} - export function PluginThreadChat({ threadId, variant = "full", @@ -273,7 +248,13 @@ function PluginThreadChatBody({ ); } if (thread === undefined) { - return ; + return ( +
+ + + +
+ ); } if (variant === "timeline") { @@ -306,42 +287,40 @@ function PluginThreadChatBody({ return ( - }> - - + ); } From da56efba5b8009c61f4a68cf8fb66f914fca1569 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 02:50:59 -0400 Subject: [PATCH 14/29] Trim duplicate delivery wiring and unrelated formatting churn --- .../src/hooks/useDurableMessageSubmission.ts | 19 +-- apps/app/src/hooks/useQueuedMessageRows.ts | 13 +- .../src/lib/message-delivery/coordinator.ts | 11 +- .../src/lib/message-delivery/store.test.ts | 11 +- .../useThreadMessageSubmissions.ts | 10 -- packages/db/test/migrate.test.ts | 150 ++++++++---------- 6 files changed, 93 insertions(+), 121 deletions(-) delete mode 100644 apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts diff --git a/apps/app/src/hooks/useDurableMessageSubmission.ts b/apps/app/src/hooks/useDurableMessageSubmission.ts index 9b427aff4dd..3c9579b3ab1 100644 --- a/apps/app/src/hooks/useDurableMessageSubmission.ts +++ b/apps/app/src/hooks/useDurableMessageSubmission.ts @@ -11,7 +11,10 @@ import type { } from "@bb/server-contract"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useServerConnectionState } from "@/hooks/useServerConnectionState"; -import { enqueueSubmission } from "@/lib/message-delivery/store"; +import { + enqueueSubmission, + type SubmissionRequest, +} from "@/lib/message-delivery/store"; import { notifyComposerSubmitted } from "@/lib/composer-submissions"; type DurableRequest = @@ -57,23 +60,21 @@ export function useDurableMessageSubmission(threadId: string) { savingRef.current = true; setIsSaving(true); try { + let ordinary: SubmissionRequest; if (submission.kind === "send") { const mode = submission.request.mode; if (mode !== "queue-if-active" && mode !== "start") { throw new Error("Steering requires a server connection."); } - const saved = await enqueueSubmission({ + ordinary = { kind: "send", request: { ...submission.request, mode }, - threadId, - draft, - awaitAcceptance, - }); - notifyComposerSubmitted({ kind: "thread", threadId }); - return saved; + }; + } else { + ordinary = submission; } const saved = await enqueueSubmission({ - ...submission, + ...ordinary, threadId, draft, awaitAcceptance, diff --git a/apps/app/src/hooks/useQueuedMessageRows.ts b/apps/app/src/hooks/useQueuedMessageRows.ts index ef3ca3602bd..b14e22b4b80 100644 --- a/apps/app/src/hooks/useQueuedMessageRows.ts +++ b/apps/app/src/hooks/useQueuedMessageRows.ts @@ -1,6 +1,9 @@ -import { useMemo } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import type { ThreadQueuedMessage } from "@bb/domain"; -import { useThreadMessageSubmissions } from "@/lib/message-delivery/useThreadMessageSubmissions"; +import { + getSubmissions, + subscribeSubmissions, +} from "@/lib/message-delivery/store"; import { projectQueuedMessageRows } from "@/lib/queued-message-rows"; import { useServerConnectionState } from "@/hooks/useServerConnectionState"; @@ -8,7 +11,11 @@ export function useQueuedMessageRows( threadId: string, serverMessages: readonly ThreadQueuedMessage[], ) { - const submissions = useThreadMessageSubmissions(threadId); + const entries = useSyncExternalStore(subscribeSubmissions, getSubmissions); + const submissions = useMemo( + () => entries.filter((entry) => entry.threadId === threadId), + [entries, threadId], + ); const connected = useServerConnectionState() === "connected"; return useMemo( () => projectQueuedMessageRows({ serverMessages, submissions, connected }), diff --git a/apps/app/src/lib/message-delivery/coordinator.ts b/apps/app/src/lib/message-delivery/coordinator.ts index 8d3294b0f8e..1a6b8babff6 100644 --- a/apps/app/src/lib/message-delivery/coordinator.ts +++ b/apps/app/src/lib/message-delivery/coordinator.ts @@ -17,7 +17,6 @@ import { export interface DeliveryFailure { kind: "transient" | "rejected" | "unknown"; message: string; - retryAfterMs?: number; resolvesUncertainty?: boolean; } @@ -48,12 +47,9 @@ function failureError(error: unknown): Error { : new Error("Message delivery could not be completed."); } -function retryDelay(entry: Submission, retryAfterMs = 0): number { +function retryDelay(entry: Submission): number { const backoff = Math.min(60_000, 1_000 * 2 ** Math.min(entry.retryCount, 6)); - return Math.max( - retryAfterMs, - Math.round(backoff * (0.8 + Math.random() * 0.4)), - ); + return Math.round(backoff * (0.8 + Math.random() * 0.4)); } export function startMessageDelivery( @@ -166,8 +162,7 @@ export function startMessageDelivery( }); options.onError?.(failureError(error)); } else { - const nextAttemptAt = - Date.now() + retryDelay(claim.entry, failure.retryAfterMs); + const nextAttemptAt = Date.now() + retryDelay(claim.entry); await settleSubmission(claim, { kind: "retry", error: failure.message, diff --git a/apps/app/src/lib/message-delivery/store.test.ts b/apps/app/src/lib/message-delivery/store.test.ts index 3d6df393a49..adec208ad3b 100644 --- a/apps/app/src/lib/message-delivery/store.test.ts +++ b/apps/app/src/lib/message-delivery/store.test.ts @@ -194,7 +194,7 @@ it("skips retained rejection and corrects it with a new ID at the delivery tail" expect(corrected?.createdAt).toBe(first.createdAt); }); -it("pauses delivery while an inline edit owns the claim and fences an expired editor", async () => { +it("pauses delivery while an inline edit owns the claim and sends the edited payload", async () => { const entry = await enqueue("Edit this"); const edit = await store.acquireSubmissionEdit({ threadId: entry.threadId, @@ -215,15 +215,6 @@ it("pauses delivery while an inline edit owns the claim and fences an expired ed expect(delivering.entry.request.input).toEqual([ { type: "text", text: "Updated", mentions: [] }, ]); - await expect( - store.editSubmission({ - threadId: entry.threadId, - id: entry.id, - expectedUpdatedAt: entry.updatedAt, - input: [{ type: "text", text: "Stale edit", mentions: [] }], - editToken: edit.token, - }), - ).rejects.toThrow("already being delivered"); }); it("resolves a programmatic caller after durable acceptance even when another reader completes cleanup", async () => { diff --git a/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts b/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts deleted file mode 100644 index 19c7b8f5131..00000000000 --- a/apps/app/src/lib/message-delivery/useThreadMessageSubmissions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useMemo, useSyncExternalStore } from "react"; -import { getSubmissions, subscribeSubmissions } from "./store"; - -export function useThreadMessageSubmissions(threadId: string) { - const entries = useSyncExternalStore(subscribeSubmissions, getSubmissions); - return useMemo( - () => entries.filter((entry) => entry.threadId === threadId), - [entries, threadId], - ); -} diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 34b250b054d..8bb1f327eb7 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -870,7 +870,9 @@ function rewindMachineProvidersMigration(db: DbConnection): void { "requested_by_thread_id", ]) { if (!queuedDispatchOrigin.some((column) => column.name === name)) continue; - db.$client.exec(`ALTER TABLE queued_thread_messages DROP COLUMN ${name}`); + db.$client.exec( + `ALTER TABLE queued_thread_messages DROP COLUMN ${name}`, + ); } db.$client.exec("DROP TABLE IF EXISTS thread_pruning_cursors"); db.$client.exec("DROP TABLE IF EXISTS project_attachment_threads"); @@ -1771,10 +1773,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - MigratedEventDataRow - >("SELECT data FROM events WHERE id = 'evt-retained-output-migration'") + .prepare<[], MigratedEventDataRow>( + "SELECT data FROM events WHERE id = 'evt-retained-output-migration'", + ) .get(), ).toEqual({ data: eventData }); expect( @@ -1845,10 +1846,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { id: string; root: string | null } - >("SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id") + .prepare<[], { id: string; root: string | null }>( + "SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id", + ) .all(), ).toEqual([ { id: "collision", root: `/cache/repo/${commit}` }, @@ -1986,10 +1986,9 @@ describe("migrate", () => { }); expect( db.$client - .prepare< - [], - { key: string; value: string } - >("SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key") + .prepare<[], { key: string; value: string }>( + "SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key", + ) .all(), ).toEqual([ { key: "claudeCodeMemoryEnabled", value: "true" }, @@ -2003,10 +2002,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { updatedAt: number } - >("SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'") + .prepare<[], { updatedAt: number }>( + "SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'", + ) .get(), ).toEqual({ updatedAt: 1234 }); } finally { @@ -2052,7 +2050,9 @@ describe("migrate", () => { .prepare< [], { pluginId: string; key: string; value: string; updatedAt: number } - >("SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key") + >( + "SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key", + ) .all(), ).toEqual([ { @@ -2088,10 +2088,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { key: string } - >("SELECT key FROM app_settings_values ORDER BY key") + .prepare<[], { key: string }>( + "SELECT key FROM app_settings_values ORDER BY key", + ) .all(), ).toEqual([{ key: "showKeyboardHints" }]); } finally { @@ -2160,10 +2159,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { count: number } - >("SELECT COUNT(*) AS count FROM app_settings_values") + .prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM app_settings_values", + ) .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db)).toEqual(defaultAppSettings); @@ -2239,10 +2237,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { count: number } - >("SELECT COUNT(*) AS count FROM app_settings_values") + .prepare<[], { count: number }>( + "SELECT COUNT(*) AS count FROM app_settings_values", + ) .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2284,10 +2281,9 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { value: string; updatedAt: number } - >("SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'") + .prepare<[], { value: string; updatedAt: number }>( + "SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'", + ) .get(), ).toEqual({ value: "true", updatedAt: 1234 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2347,10 +2343,9 @@ describe("migrate", () => { runMigrationFile({ db, migrationPath: sideChatPluginOnlyMigrationPath }); const rows = db.$client - .prepare< - [], - MigratedThreadOriginRow - >("SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads") + .prepare<[], MigratedThreadOriginRow>( + "SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads", + ) .all(); const byId = new Map(rows.map((row) => [row.id, row])); @@ -5269,10 +5264,9 @@ describe("migrate", () => { expect(readTableNames(db)).not.toContain("marketplaces"); expect( db.$client - .prepare< - [], - { source: string } - >("SELECT source FROM plugins WHERE id = 'third-party'") + .prepare<[], { source: string }>( + "SELECT source FROM plugins WHERE id = 'third-party'", + ) .get(), ).toEqual({ source: "git:https://example.test/tasks@main" }); } finally { @@ -5342,10 +5336,9 @@ describe("migrate", () => { }); expect( db.$client - .prepare< - [], - MigrationCountRow - >("SELECT COUNT(*) AS count FROM plugin_catalog") + .prepare<[], MigrationCountRow>( + "SELECT COUNT(*) AS count FROM plugin_catalog", + ) .get(), ).toEqual({ count: 0 }); } finally { @@ -5390,18 +5383,16 @@ describe("migrate", () => { expect( db.$client - .prepare< - [], - { name: string } - >("SELECT name FROM plugin_marketplaces ORDER BY name") + .prepare<[], { name: string }>( + "SELECT name FROM plugin_marketplaces ORDER BY name", + ) .all(), ).toEqual([{ name: "acme" }, { name: "bb-community" }]); expect( db.$client - .prepare< - [], - { marketplaceName: string } - >("SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name") + .prepare<[], { marketplaceName: string }>( + "SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name", + ) .all(), ).toEqual([ { marketplaceName: "acme" }, @@ -5409,10 +5400,9 @@ describe("migrate", () => { ]); expect( db.$client - .prepare< - [], - { id: string; catalogMarketplaceName: string | null } - >("SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id") + .prepare<[], { id: string; catalogMarketplaceName: string | null }>( + "SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id", + ) .all(), ).toEqual([ { id: "local", catalogMarketplaceName: null }, @@ -5425,7 +5415,9 @@ describe("migrate", () => { .prepare< [], { name: string; etag: string | null; lastModified: string | null } - >("SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name") + >( + "SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name", + ) .all(), ).toEqual([ { @@ -5720,9 +5712,9 @@ describe("environment providers migration", () => { rewindEnvironmentRowFactsMigration(db); rewindEnvironmentProvidersMigration(db); db.$client - .prepare< - [number] - >("DELETE FROM __drizzle_migrations WHERE created_at >= ?") + .prepare<[number]>( + "DELETE FROM __drizzle_migrations WHERE created_at >= ?", + ) .run(environmentProvidersMigrationWhen); db.$client.exec(` INSERT INTO hosts (id, name, type, created_at, updated_at) @@ -5797,10 +5789,9 @@ describe("environment providers migration", () => { seedPreProviderEnvironments(db); migrate(db); const rows = db.$client - .prepare< - [], - { provider: string; owner: string | null } - >("SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider") + .prepare<[], { provider: string; owner: string | null }>( + "SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider", + ) .all(); expect(rows).toEqual([ { provider: "git-worktree", owner: "environment-git-worktree" }, @@ -6025,10 +6016,9 @@ describe("environment providers migration", () => { expect( db.$client - .prepare< - [], - { id: string; status: string } - >("SELECT id, status FROM environments ORDER BY id") + .prepare<[], { id: string; status: string }>( + "SELECT id, status FROM environments ORDER BY id", + ) .all(), ).toEqual([ { id: "env_default", status: "ready" }, @@ -6112,10 +6102,9 @@ describe("machine providers migration", () => { expect( db.$client - .prepare< - [], - { id: string; providerId: string | null; type: string } - >("SELECT id, server_access_provider_id AS providerId, type FROM hosts ORDER BY id") + .prepare<[], { id: string; providerId: string | null; type: string }>( + "SELECT id, server_access_provider_id AS providerId, type FROM hosts ORDER BY id", + ) .all(), ).toEqual([ { id: "direct", providerId: null, type: "persistent" }, @@ -6145,9 +6134,9 @@ describe("environment and thread startup ownership migration", () => { db.$client.exec(legacySchema); db.$client.exec("DROP TABLE IF EXISTS environment_hook_operations"); db.$client - .prepare< - [number] - >("DELETE FROM __drizzle_migrations WHERE created_at >= ?") + .prepare<[number]>( + "DELETE FROM __drizzle_migrations WHERE created_at >= ?", + ) .run(environmentProvisioningMigrationWhen); db.$client.exec(` INSERT INTO hosts (id, name, type, created_at, updated_at) VALUES ('host_ownership', 'test', 'persistent', 1, 1); @@ -6227,10 +6216,9 @@ describe("environment and thread startup ownership migration", () => { .get(), ).toEqual({ resource: '{"new":"checkpoint"}' }); const stored = db.$client - .prepare< - [], - { startup_context: string } - >("SELECT startup_context FROM threads WHERE id = 'thr_creating'") + .prepare<[], { startup_context: string }>( + "SELECT startup_context FROM threads WHERE id = 'thr_creating'", + ) .get()!; expect(JSON.parse(stored.startup_context).state).not.toHaveProperty( "stage", From e42dab33efaff3fd9e5188dcb08edd900560e3c9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 05:21:32 -0400 Subject: [PATCH 15/29] Preserve explicit Send now for durable queued messages --- .../src/services/threads/queued-messages.ts | 1 + .../thread-submission-receipts.test.ts | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 54aee305967..f6f7ee28e37 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -753,6 +753,7 @@ async function sendClaimedQueuedMessageForThread( ), ...(inputGroups.length > 1 ? { inputGroups } : {}), ...(args.mode === "auto" && + !args.sendNow && queuedMessages.some((entry) => entry.clientSubmissionId !== undefined) ? { mode: "queue-if-active" as const } : {}), diff --git a/apps/server/test/threads/thread-submission-receipts.test.ts b/apps/server/test/threads/thread-submission-receipts.test.ts index dfcc0febe65..d9b669b2207 100644 --- a/apps/server/test/threads/thread-submission-receipts.test.ts +++ b/apps/server/test/threads/thread-submission-receipts.test.ts @@ -23,8 +23,12 @@ import { import { sendMessageResponseSchema } from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import { acceptThreadSendRequest } from "../../src/services/threads/thread-send-request.js"; -import { createQueuedMessageForThread } from "../../src/services/threads/queued-messages.js"; +import { + createQueuedMessageForThread, + sendQueuedMessageNow, +} from "../../src/services/threads/queued-messages.js"; import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; +import { listQueuedThreadCommands } from "../helpers/commands.js"; import { textInput } from "../helpers/prompt-input.js"; import { seedEnvironment, @@ -33,6 +37,7 @@ import { seedThread, seedThreadRuntimeState, seedEvent, + seedTurnStarted, } from "../helpers/seed.js"; import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; @@ -416,6 +421,47 @@ describe("durable submission acceptance", () => { }); }); + it("waits automatically but lets Send now join the active turn for an accepted submission", async () => { + await withTestHarness(async (harness) => { + const { thread, environment } = fixture(harness, "active"); + seedTurnStarted(harness.deps, { + environmentId: environment.id, + providerThreadId: "provider-submission", + threadId: thread.id, + turnId: "turn-submission", + }); + const before = requestCount(harness, thread.id); + const input = textInput("send this accepted follow-up now"); + const queued = await createQueuedMessageForThread(harness.deps, { + thread, + payload: { clientSubmissionId, input }, + }); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(requestCount(harness, thread.id)).toBe(before); + expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ + { id: queued.id, clientSubmissionId }, + ]); + + await expect( + sendQueuedMessageNow(harness.deps, { + mode: "auto", + queuedMessageId: queued.id, + threadId: thread.id, + }), + ).resolves.toEqual({ delivery: "sent" }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toEqual([]); + expect(requestCount(harness, thread.id)).toBe(before + 1); + expect( + listQueuedThreadCommands(harness, "turn.submit", thread.id), + ).toMatchObject([ + { input, target: { mode: "auto", expectedTurnId: "turn-submission" } }, + ]); + }); + }); + it.each(["idle", "error"] as const)( "wakes an accepted queued follow-up on an %s thread", async (status) => { From 916827d75e1e68ad576ca94e61042ac48429d5e1 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 20:20:31 -0400 Subject: [PATCH 16/29] Reuse shared exponential backoff for message delivery --- .../src/lib/message-delivery/coordinator.test.ts | 1 - apps/app/src/lib/message-delivery/coordinator.ts | 14 ++++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/app/src/lib/message-delivery/coordinator.test.ts b/apps/app/src/lib/message-delivery/coordinator.test.ts index 8753f13bdd5..a02f1a071f6 100644 --- a/apps/app/src/lib/message-delivery/coordinator.test.ts +++ b/apps/app/src/lib/message-delivery/coordinator.test.ts @@ -19,7 +19,6 @@ beforeEach(async () => { vi.stubGlobal("IDBKeyRange", IDBKeyRange); store = await import("./store"); coordinator = await import("./coordinator"); - vi.spyOn(Math, "random").mockReturnValue(0.5); }); afterEach(async () => { diff --git a/apps/app/src/lib/message-delivery/coordinator.ts b/apps/app/src/lib/message-delivery/coordinator.ts index 1a6b8babff6..bcb0f1ed97a 100644 --- a/apps/app/src/lib/message-delivery/coordinator.ts +++ b/apps/app/src/lib/message-delivery/coordinator.ts @@ -1,4 +1,5 @@ import { nanoid } from "nanoid"; +import { calculateExponentialBackoffDelay } from "@bb/domain"; import { claimSubmission, completeSubmissionHandoff, @@ -47,11 +48,6 @@ function failureError(error: unknown): Error { : new Error("Message delivery could not be completed."); } -function retryDelay(entry: Submission): number { - const backoff = Math.min(60_000, 1_000 * 2 ** Math.min(entry.retryCount, 6)); - return Math.round(backoff * (0.8 + Math.random() * 0.4)); -} - export function startMessageDelivery( options: MessageDeliveryOptions, ): () => void { @@ -162,7 +158,13 @@ export function startMessageDelivery( }); options.onError?.(failureError(error)); } else { - const nextAttemptAt = Date.now() + retryDelay(claim.entry); + const nextAttemptAt = + Date.now() + + calculateExponentialBackoffDelay({ + attempt: claim.entry.retryCount + 1, + baseDelayMs: 1_000, + maxDelayMs: 60_000, + }); await settleSubmission(claim, { kind: "retry", error: failure.message, From 64da9d246183d9943abc582b19776a32856cfee4 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 07:37:28 -0400 Subject: [PATCH 17/29] Remove redundant message delivery validation --- .../useQueuedMessageActions.test.ts | 4 +-- .../embedded-chat/useQueuedMessageActions.ts | 2 +- .../src/hooks/useDurableMessageSubmission.ts | 26 +++++++------------ apps/app/src/lib/message-delivery/store.ts | 6 ++--- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts index 48f68cd0c1c..2fbccbde1b4 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts @@ -109,12 +109,12 @@ afterEach(() => { }); describe("local queued-message action boundaries", () => { - it("never sends an unaccepted ID even with guard none, and blocks uncertain edits, deletes, and mixed reorders", async () => { + it("blocks unaccepted sends, uncertain edits and deletes, and mixed reorders", async () => { const row = localRow(false); const { result } = renderActions(row); await act(async () => { await result.current.sendQueuedMessageById({ - guard: "none", + guard: "exists", messageId: row.id, mode: "auto", }); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts index 7def7e0d7b7..fe29bfd956a 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts @@ -22,7 +22,7 @@ import { } from "@/lib/queued-message-rows"; import { deleteSubmission, editSubmission } from "@/lib/message-delivery/store"; -type QueuedMessageSendGuard = "current-head" | "exists" | "none"; +type QueuedMessageSendGuard = "current-head" | "exists"; interface SendQueuedMessageByIdArgs { guard: QueuedMessageSendGuard; diff --git a/apps/app/src/hooks/useDurableMessageSubmission.ts b/apps/app/src/hooks/useDurableMessageSubmission.ts index 3c9579b3ab1..0241b05deb4 100644 --- a/apps/app/src/hooks/useDurableMessageSubmission.ts +++ b/apps/app/src/hooks/useDurableMessageSubmission.ts @@ -1,9 +1,5 @@ import { useCallback, useRef, useState } from "react"; -import { - isStandaloneBuiltinClearCommand, - isStandaloneBuiltinCompactCommand, - type PromptInput, -} from "@bb/domain"; +import type { PromptInput } from "@bb/domain"; import type { PromptDraftState } from "@bb/client-core"; import type { CreateQueuedMessageRequest, @@ -22,18 +18,14 @@ type DurableRequest = | { kind: "queue"; request: CreateQueuedMessageRequest }; export function isDurableMessageInput(input: readonly PromptInput[]): boolean { - return ( - !isStandaloneBuiltinClearCommand(input) && - !isStandaloneBuiltinCompactCommand(input) && - !input.some( - (block) => - block.type === "text" && - block.mentions.some( - (mention) => - mention.resource.kind === "command" && - mention.resource.source === "command", - ), - ) + return !input.some( + (block) => + block.type === "text" && + block.mentions.some( + (mention) => + mention.resource.kind === "command" && + mention.resource.source === "command", + ), ); } diff --git a/apps/app/src/lib/message-delivery/store.ts b/apps/app/src/lib/message-delivery/store.ts index 462bb303984..2502fab6474 100644 --- a/apps/app/src/lib/message-delivery/store.ts +++ b/apps/app/src/lib/message-delivery/store.ts @@ -19,9 +19,7 @@ const acceptedSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("send"), result: sendMessageResponseSchema }), z.object({ kind: z.literal("queue"), - result: threadQueuedMessageSchema.extend({ - replayed: z.boolean().optional(), - }), + result: threadQueuedMessageSchema, }), ]); const draftSchema = z.object({ @@ -550,7 +548,7 @@ export async function settleSubmission( if (settlement.kind === "accepted") next = submissionSchema.parse({ ...current, - accepted: acceptedSchema.parse(settlement.accepted), + accepted: settlement.accepted, status: "accepted", error: null, nextAttemptAt: 0, From 7a9a48c55fb4cd43d38961899fc2c9269f12550f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 12:56:24 -0400 Subject: [PATCH 18/29] Reduce queue failure fix to existing draft and mutation handling --- apps/app/bundle-budget.json | 2 +- apps/app/package.json | 1 - apps/app/src/App.tsx | 8 - .../promptbox/FollowUpPromptBox.tsx | 12 +- .../promptbox/PromptBoxInternal.test.tsx | 132 - .../promptbox/PromptBoxInternal.tsx | 19 +- .../banner/QueuedMessagesList.test.tsx | 83 +- .../promptbox/banner/QueuedMessagesList.tsx | 121 +- .../embedded-chat/EmbeddedThreadChat.test.tsx | 195 +- .../embedded-chat/EmbeddedThreadChat.tsx | 123 +- .../useInlineQueuedMessageEditing.test.ts | 182 - .../useInlineQueuedMessageEditing.ts | 172 +- .../useQueuedMessageActions.test.ts | 187 - .../embedded-chat/useQueuedMessageActions.ts | 131 +- .../thread-runtime-cache-owner.test.ts | 54 - .../thread-runtime-cache-owner.ts | 34 - .../thread-runtime-mutations.test.tsx | 103 +- .../mutations/thread-runtime-mutations.ts | 56 +- .../useDurableMessageSubmission.test.tsx | 135 - .../src/hooks/useDurableMessageSubmission.ts | 85 - .../src/hooks/usePromptDraftStorage.test.tsx | 123 - apps/app/src/hooks/usePromptDraftStorage.ts | 107 +- apps/app/src/hooks/useQueuedMessageRows.ts | 24 - .../message-delivery/MessageDeliverySync.tsx | 139 - .../lib/message-delivery/coordinator.test.ts | 234 - .../src/lib/message-delivery/coordinator.ts | 298 - .../src/lib/message-delivery/store.test.ts | 310 - apps/app/src/lib/message-delivery/store.ts | 808 --- apps/app/src/lib/queued-message-rows.test.ts | 135 - apps/app/src/lib/queued-message-rows.ts | 87 - ...ThreadDetailPromptArea.keystrokes.test.tsx | 18 - .../ThreadDetailPromptArea.test.tsx | 251 +- .../thread-detail/ThreadDetailPromptArea.tsx | 166 +- .../views/thread-detail/ThreadDetailView.tsx | 2 +- .../command-output/thread-tell.test.ts | 43 - apps/cli/src/commands/thread/actions.ts | 15 +- apps/cli/src/commands/thread/organization.ts | 8 - apps/server/src/routes/system.ts | 5 +- .../src/services/threads/dispatch-attempt.ts | 91 +- .../src/services/threads/queue-waits.ts | 47 +- .../src/services/threads/queued-messages.ts | 129 +- .../services/threads/thread-provisioning.ts | 81 +- .../threads/thread-queued-messages.ts | 4 - .../services/threads/thread-send-request.ts | 29 +- .../src/services/threads/thread-send.ts | 90 +- .../threads/thread-submission-receipts.ts | 250 - .../services/threads/thread-turn-dispatch.ts | 3 - .../test/system/general-settings.test.ts | 1 - .../test/threads/dispatch-hooks.test.ts | 41 - .../thread-submission-receipts.test.ts | 611 -- .../test/threads/thread-turn-dispatch.test.ts | 69 - .../db/drizzle/0127_volatile_meteorite.sql | 12 - packages/db/drizzle/meta/0127_snapshot.json | 5082 ----------------- packages/db/drizzle/meta/_journal.json | 7 - .../db/src/data/queued-thread-messages.ts | 2 - packages/db/src/schema.ts | 18 - packages/db/test/migrate.test.ts | 2 - packages/domain/src/feature-flags.ts | 1 - packages/domain/src/plugin-sdk-version.ts | 2 +- packages/domain/src/thread.ts | 4 - packages/plugin-sdk/package.json | 2 +- packages/sdk/src/areas/threads.ts | 27 +- packages/sdk/test/sdk.test.ts | 40 - packages/server-contract/src/api/threads.ts | 16 +- .../server-contract/test/contract.test.ts | 45 +- .../src/templates/bb-guide-threads.md | 20 +- .../bb-cli/references/thread-operation.md | 10 - pnpm-lock.yaml | 9 - 68 files changed, 470 insertions(+), 10883 deletions(-) delete mode 100644 apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts delete mode 100644 apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts delete mode 100644 apps/app/src/hooks/useDurableMessageSubmission.test.tsx delete mode 100644 apps/app/src/hooks/useDurableMessageSubmission.ts delete mode 100644 apps/app/src/hooks/useQueuedMessageRows.ts delete mode 100644 apps/app/src/lib/message-delivery/MessageDeliverySync.tsx delete mode 100644 apps/app/src/lib/message-delivery/coordinator.test.ts delete mode 100644 apps/app/src/lib/message-delivery/coordinator.ts delete mode 100644 apps/app/src/lib/message-delivery/store.test.ts delete mode 100644 apps/app/src/lib/message-delivery/store.ts delete mode 100644 apps/app/src/lib/queued-message-rows.test.ts delete mode 100644 apps/app/src/lib/queued-message-rows.ts delete mode 100644 apps/server/src/services/threads/thread-submission-receipts.ts delete mode 100644 apps/server/test/threads/thread-submission-receipts.test.ts delete mode 100644 packages/db/drizzle/0127_volatile_meteorite.sql delete mode 100644 packages/db/drizzle/meta/0127_snapshot.json diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 44f374229e2..51098e2298f 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -47,7 +47,7 @@ "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1728032, + "maxBootBytes": 1723936, "maxBootBrotliBytes": 429072, "forbiddenBootPackages": [ "@pierre/diffs", diff --git a/apps/app/package.json b/apps/app/package.json index b6e2554dfa6..baefcfb0080 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -115,7 +115,6 @@ "babel-plugin-react-compiler": "^1.0.0", "bb-plugin-automations": "workspace:*", "cacache": "^20.0.4", - "fake-indexeddb": "^6.2.5", "lightningcss": "^1.32.0", "mdast-util-to-hast": "^13.2.1", "oniguruma-to-es": "^4.3.4", diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 4e4b11442c6..4bc8707e8c0 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -69,11 +69,6 @@ import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provi import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; -const MessageDeliverySync = lazy(() => - import("@/lib/message-delivery/MessageDeliverySync").then((m) => ({ - default: m.MessageDeliverySync, - })), -); const SettingsView = lazy(() => import("./views/SettingsView").then((m) => ({ default: m.SettingsView, @@ -438,9 +433,6 @@ export function App() { - - - onModifierSubmit ? `, ${modifierSubmitShortcutLabel()} to ${action}` : ""; const executionControlsDisabled = - (executionReadOnly ?? false) || - hasPendingInteraction || - composer.isFollowUpSubmitting; + (executionReadOnly ?? false) || hasPendingInteraction; const footerStart = useMemo( () => ( @@ -624,9 +621,7 @@ function FollowUpPromptBoxWithComposer({ activePromptMode, }); const permissionReadOnlyResolved = - (permissionReadOnly ?? false) || - hasPendingInteraction || - composer.isFollowUpSubmitting; + (permissionReadOnly ?? false) || hasPendingInteraction; const permissionPickerDisabled = permissionReadOnlyResolved || permissionPickerDisabledByPlanMode; const permissionControl = useMemo( @@ -731,13 +726,12 @@ function FollowUpPromptBoxWithComposer({ isSubmitting: composer.isFollowUpSubmitting, disabled: !canSubmit || - composer.submitDisabled === true || composer.isFollowUpSubmitting || (steerOnPrimarySubmit && !composer.canModifierSubmit), onModifierSubmit, title: composer.isFollowUpSubmitting ? "Submitting..." - : composer.submitTitle !== undefined + : canSubmit && composer.submitTitle !== undefined ? composer.submitTitle : isStopping ? "Queue for after the stop (Enter)" diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 9a7f426f2f5..7db78fbfb61 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1198,138 +1198,6 @@ describe("PromptBoxInternal controlled value sync", () => { }); describe("PromptBoxInternal submit shortcuts", () => { - it("keeps text and attachments unchanged while submitting and unlocks when settled", async () => { - const onChange = vi.fn(); - const onAttachFiles = vi.fn(); - const onRemove = vi.fn(); - const promptBoxRef = createRef(); - const props = createPromptBoxProps({ - value: "Keep this message", - onChange, - promptBoxRef, - attachments: { - items: [ - { - type: "localFile", - name: "notes.txt", - path: "notes.txt", - sizeBytes: 1, - }, - ], - onAttachFiles, - onRemove, - }, - }); - const view = render( - , - ); - const editor = getPromptEditorElement(); - await waitFor(() => - expect(editor.getAttribute("contenteditable")).toBe("false"), - ); - onChange.mockClear(); - const file = new File(["new attachment"], "new.txt", { - type: "text/plain", - }); - const attachmentInput = view.container.querySelector('input[type="file"]'); - if (!(attachmentInput instanceof HTMLInputElement)) - throw new Error("Attachment input was not rendered"); - pasteClipboard({ files: [file], plainText: "overwrite" }); - fireEvent.change(attachmentInput, { target: { files: [file] } }); - fireEvent.drop(editor, { dataTransfer: { files: [file] } }); - expect( - screen.queryByRole("button", { name: "Remove notes.txt" }), - ).toBeNull(); - expect(editor.textContent).toBe("Keep this message"); - expect(onChange).not.toHaveBeenCalled(); - expect(onAttachFiles).not.toHaveBeenCalled(); - expect(onRemove).not.toHaveBeenCalled(); - - view.rerender( - , - ); - await waitFor(() => - expect(editor.getAttribute("contenteditable")).toBe("true"), - ); - await focusPromptEnd(promptBoxRef); - pastePlainText(" after failure"); - await waitFor(() => - expect(onChange).toHaveBeenLastCalledWith( - "Keep this message after failure", - [], - ), - ); - fireEvent.change(attachmentInput, { target: { files: [file] } }); - expect(onAttachFiles).toHaveBeenCalledWith([file]); - fireEvent.click(screen.getByRole("button", { name: "Remove notes.txt" })); - expect(onRemove).toHaveBeenCalledWith("notes.txt"); - }); - - it("does not restore submitted text when acceptance clears and unlocks the editor", async () => { - const onChange = vi.fn(); - const props = createPromptBoxProps({ value: "Accepted message", onChange }); - const view = render( - , - ); - await waitFor(() => - expect(getPromptEditorElement().getAttribute("contenteditable")).toBe( - "false", - ), - ); - onChange.mockClear(); - view.rerender( - , - ); - await waitFor(() => expect(getPromptEditorElement().textContent).toBe("")); - expect(getPromptEditorElement().getAttribute("contenteditable")).toBe( - "true", - ); - expect(onChange).not.toHaveBeenCalled(); - }); - - it("prevents history recall while submitting and restores it when settled", async () => { - const onSelectEntry = vi.fn(); - const promptBoxRef = createRef(); - const previousDraft = { - ...emptyPromptDraftState(), - text: "Previous message", - }; - const props = createPromptBoxProps({ - promptBoxRef, - history: { - currentDraft: emptyPromptDraftState(), - entries: [previousDraft], - onSelectEntry, - resetKey: "submission-history", - }, - }); - const view = render( - , - ); - const editor = getPromptEditorElement(); - await waitFor(() => - expect(editor.getAttribute("contenteditable")).toBe("false"), - ); - await focusPromptEnd(promptBoxRef); - fireEvent.keyDown(editor, { key: "ArrowUp" }); - expect(onSelectEntry).not.toHaveBeenCalled(); - expect(editor.textContent).toBe(""); - - view.rerender( - , - ); - await waitFor(() => - expect(editor.getAttribute("contenteditable")).toBe("true"), - ); - await focusPromptEnd(promptBoxRef); - fireEvent.keyDown(editor, { key: "ArrowUp" }); - expect(onSelectEntry).toHaveBeenCalledWith(previousDraft); - }); - it("exposes the disabled submit reason as its label and hover tooltip", async () => { const reason = "Loading models from the selected machine..."; render( diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 9f2e4417ca3..26f5b29d8c9 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -1260,12 +1260,10 @@ export function PromptBoxInternal({ items: attachments = [], isAttaching = false, error: attachmentError = null, - onAttachFiles: attachFiles, - onRemove: removeAttachment, + onAttachFiles, + onRemove: onRemoveAttachment, projectId: attachmentProjectId, } = attachmentConfig; - const onAttachFiles = isSubmitting ? undefined : attachFiles; - const onRemoveAttachment = isSubmitting ? undefined : removeAttachment; const isPointerCoarse = usePointerCoarse(); const isIPadOSWebKitDevice = useMemo(isIPadOSWebKit, []); const editorEnterKeyHint = isPointerCoarse ? "enter" : "send"; @@ -1464,10 +1462,9 @@ export function PromptBoxInternal({ ? (compact.placeholder ?? placeholder) : placeholder; const pluginComposerHost = usePluginComposerHost(); - const pluginInputLocked = useComposerInputLock( + const composerInputLocked = useComposerInputLock( pluginComposerHost?.textEffectKey ?? null, ); - const composerInputLocked = pluginInputLocked || isSubmitting; const composerLayout = showCompactLayout ? "compact" : "expanded"; const localComposerView = usePluginComposerViewModel({ scope: pluginComposerHost?.scope ?? DEFAULT_COMPOSER_SCOPE, @@ -1901,10 +1898,10 @@ export function PromptBoxInternal({ [richTextEditing], ); - useLayoutEffect(() => { + useEffect(() => { if (!editor || editor.isDestroyed) return; const editable = !composerInputLocked && !showVoiceActionGroup; - if (editor.isEditable !== editable) editor.setEditable(editable, false); + if (editor.isEditable !== editable) editor.setEditable(editable); editor.view.dom.tabIndex = editable ? 0 : -1; if (editable) { editor.view.dom.removeAttribute("aria-readonly"); @@ -2793,7 +2790,7 @@ export function PromptBoxInternal({ const applyHistoryDraft = useCallback( (draft: PromptDraftState) => { - if (!history || isSubmitting) { + if (!history) { return; } @@ -2809,7 +2806,7 @@ export function PromptBoxInternal({ scheduleRevealEditorSelection(); }); }, - [history, isSubmitting, scheduleRevealEditorSelection, syncTriggerState], + [history, scheduleRevealEditorSelection, syncTriggerState], ); const collapsePromptBox = useCallback(() => { @@ -2857,7 +2854,6 @@ export function PromptBoxInternal({ ) { return false; } - if (isSubmitting) return false; if (dispatchAppCommandKey(event)) { return true; } @@ -3085,7 +3081,6 @@ export function PromptBoxInternal({ dismissActiveTrigger, history, isPointerCoarse, - isSubmitting, loadMoreCommands, onEscape, onModifierSubmit, diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx index 801aaa22e68..551312a5d54 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx @@ -12,10 +12,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { threadsQueryKey } from "@/hooks/queries/query-keys"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadQueuedMessage } from "@bb/domain"; -import type { - LocalQueuedMessageRow, - QueuedMessageRow, -} from "@/lib/queued-message-rows"; import { makeThreadListEntry, makeThreadQueuedMessage, @@ -122,7 +118,7 @@ function rect({ top, bottom }: { top: number; bottom: number }) { } function renderQueuedMessages( - queuedMessages: readonly QueuedMessageRow[], + queuedMessages: readonly ThreadQueuedMessage[], attachedToComposer = true, ) { return render( @@ -1983,80 +1979,3 @@ describe("queued row affordances", () => { expect(queryByLabelText("Delete queued message 1")).not.toBeNull(); }); }); - -describe("local message delivery rows", () => { - function localRow(editable: boolean): LocalQueuedMessageRow { - return { - source: "local", - id: "local-pending", - threadId: "thr_prompt_pills", - clientSubmissionId: "submission-pending", - content: [{ type: "text", text: "Saved before reconnect", mentions: [] }], - model: undefined, - reasoningLevel: undefined, - permissionMode: undefined, - serviceTier: undefined, - initiator: "user", - senderThreadId: null, - groupWithNext: false, - createdAt: 1, - updatedAt: 1, - editable, - deliveryStatus: editable ? "waiting" : "confirming", - error: null, - }; - } - - it("keeps Send now visible but disabled while an uncertain row blocks editing and deletion", () => { - const { getByRole, getByText } = renderQueuedMessages([localRow(false)]); - expect(getByText("Confirming delivery")).toBeTruthy(); - expect( - getByRole("button", { name: "Send queued message 1 now" }).hasAttribute( - "disabled", - ), - ).toBe(true); - expect( - getByRole("button", { name: "Edit queued message 1" }).hasAttribute( - "disabled", - ), - ).toBe(true); - expect( - getByRole("button", { name: "Delete queued message 1" }).hasAttribute( - "disabled", - ), - ).toBe(true); - }); - - it("uses the existing actions for unattempted rows and disables grouping across server and local rows", () => { - const { getByRole, getByText } = renderQueuedMessages([ - makeQueuedMessage("server-one", "Server queued"), - localRow(true), - ]); - expect(getByText("Waiting for connection")).toBeTruthy(); - expect( - getByRole("button", { name: "Send queued message 2 now" }).hasAttribute( - "disabled", - ), - ).toBe(true); - expect( - getByRole("button", { name: "Edit queued message 2" }).hasAttribute( - "disabled", - ), - ).toBe(false); - expect( - getByRole("button", { name: "Delete queued message 2" }).hasAttribute( - "disabled", - ), - ).toBe(false); - expect( - getByRole("button", { name: "Reorder queued message 1" }).hasAttribute( - "disabled", - ), - ).toBe(true); - expect( - getByRole("button", { - name: "Messages above send together", - }).hasAttribute("disabled"), - ).toBe(true); - }); -}); diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 1baae4078ee..3357fa7e79d 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -58,11 +58,6 @@ import type { PromptTextMention, ThreadQueuedMessage, } from "@bb/domain"; -import { - getServerQueuedMessages, - isLocalQueuedMessage, - type QueuedMessageRow as QueuedMessageRowData, -} from "@/lib/queued-message-rows"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { @@ -128,7 +123,7 @@ export interface QueuedMessageInlineEditor { export interface QueuedMessagesListProps { attachedToComposer: boolean; - queuedMessages: readonly QueuedMessageRowData[]; + queuedMessages: readonly ThreadQueuedMessage[]; resolveMentionLink?: PromptMentionLinkResolver; sendAction: QueuedMessageSendAction; sendDisabled: boolean; @@ -154,7 +149,7 @@ interface QueuedMessagePreviewText { interface QueuedMessageRowProps { senderLabel: string | null; - queuedMessage: QueuedMessageRowData; + queuedMessage: ThreadQueuedMessage; resolveMentionLink?: PromptMentionLinkResolver; index: number; isProcessing: boolean; @@ -193,7 +188,7 @@ function getDrawerHeight({ queuedMessages, processingMessageId, }: { - queuedMessages: readonly QueuedMessageRowData[]; + queuedMessages: readonly ThreadQueuedMessage[]; processingMessageId: string | null; }): number { const rowsHeight = @@ -204,7 +199,6 @@ function getDrawerHeight({ total + DRAWER_ROW_HEIGHT + (queuedMessage.initiator !== "user" || - isLocalQueuedMessage(queuedMessage) || queuedMessageHasWaitLine(queuedMessage) || queuedMessage.id === processingMessageId ? queuedMessage.initiator === "agent" && @@ -637,7 +631,7 @@ function QueuedMessageFallbackTitle({ queuedMessage, }: { compact: boolean; - queuedMessage: Exclude; + queuedMessage: ThreadQueuedMessage; }) { const now = useSecondTick(); const title = queuedMessageFallbackTitle({ @@ -658,7 +652,7 @@ function QueuedMessagePreview({ resolveMentionLink, }: { compact: boolean; - queuedMessage: QueuedMessageRowData; + queuedMessage: ThreadQueuedMessage; resolveMentionLink?: PromptMentionLinkResolver; }) { const preview = useMemo( @@ -673,10 +667,7 @@ function QueuedMessagePreview({ [queuedMessage.content], ); - if ( - !isLocalQueuedMessage(queuedMessage) && - queuedMessage.payload.kind === "retry" - ) { + if (queuedMessage.payload.kind === "retry") { return (
; + queuedMessage: ThreadQueuedMessage; }) { const now = useSecondTick(); const label = describeQueuedMessageWait({ @@ -804,27 +795,21 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ () => countQueuedMessageAttachments(queuedMessage.content), [queuedMessage.content], ); - const local = isLocalQueuedMessage(queuedMessage); const pluginDisplayName = usePluginDisplayName( - !local && queuedMessage.waitingOn?.kind === "plugin" + queuedMessage.waitingOn?.kind === "plugin" ? queuedMessage.waitingOn.pluginId : "", ); - const hasWaitLine = - isLocalQueuedMessage(queuedMessage) || - queuedMessageHasWaitLine(queuedMessage); + const hasWaitLine = queuedMessageHasWaitLine(queuedMessage); const sendAllowed = - local || sendAction === "steer-when-ready" || isQueuedMessageSendNowAllowed(queuedMessage.waitingOn); const sendAriaLabel = - !local && sendAction === "steer-when-ready" + sendAction === "steer-when-ready" ? `Steer queued message ${index + 1} when ready` : `Send queued message ${index + 1} now`; const sendLabel = - !local && sendAction === "steer-when-ready" - ? "Steer when ready" - : "Send now"; + sendAction === "steer-when-ready" ? "Steer when ready" : "Send now"; const { attributes, isDragging, @@ -953,39 +938,6 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ) : null} {isProcessing ? ( - ) : local ? ( -
- - - {queuedMessage.deliveryStatus === "rejected" - ? (queuedMessage.error ?? - "Message could not be delivered") - : queuedMessage.deliveryStatus === "waiting" - ? "Waiting for connection" - : "Confirming delivery"} - -
) : hasWaitLine ? ( { - if (!local) onSend(queuedMessage.id); - }} + disabled={actionDisabled || sendDisabled} + onClick={() => onSend(queuedMessage.id)} aria-label={sendAriaLabel} > @@ -1036,7 +986,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ) : null} - {queuedMessage.editable || local ? ( + {queuedMessage.editable ? ( @@ -335,7 +306,7 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ useCreateThreadQueuedMessage: () => ({ mutateAsync: mocks.createQueuedMessageMutateAsync, mutate: vi.fn(), - isPending: mocks.createQueuedMessageIsPending, + isPending: false, }), useSendThreadMessage: () => ({ mutateAsync: mocks.sendThreadMessageMutateAsync, @@ -430,10 +401,6 @@ function renderEmbeddedChat( describe("EmbeddedThreadChat", () => { beforeEach(() => { - mocks.createQueuedMessageIsPending = false; - mocks.durableEnabled = false; - mocks.serverConnected = true; - mocks.durableEnqueue.mockReset().mockResolvedValue(undefined); window.localStorage.clear(); mocks.createQueuedMessageMutateAsync.mockReset().mockResolvedValue({}); mocks.sendThreadMessageMutateAsync.mockReset().mockResolvedValue({}); @@ -533,97 +500,6 @@ describe("EmbeddedThreadChat", () => { expect(mocks.injectedTimelineProps.at(-1)).toBeUndefined(); }); - it("disables idle Send while disconnected without locking the draft", () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - renderEmbeddedChat(); - const composer = screen.getByTestId( - "embedded-chat-composer", - ); - expect( - screen.getByRole("button", { name: "Send" }).disabled, - ).toBe(true); - expect(composer.readOnly).toBe(false); - fireEvent.change(composer, { target: { value: "Keep writing offline" } }); - expect(composer.value).toBe("Keep writing offline"); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - expect(mocks.durableEnqueue).not.toHaveBeenCalled(); - expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - }); - - it("hands an offline busy-thread submission to durable storage and permits the next draft after local save", async () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - mocks.threadRuntimeDisplayStatus = "active"; - const saved = createDeferredPromise(); - mocks.durableEnqueue.mockReturnValueOnce(saved.promise); - renderEmbeddedChat(); - fireEvent.change(screen.getByTestId("embedded-chat-composer"), { - target: { value: "Deliver after reconnect" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - expect(mocks.durableEnqueue).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "queue", - request: expect.objectContaining({ - input: [ - { type: "text", text: "Deliver after reconnect", mentions: [] }, - ], - }), - }), - expect.objectContaining({ - storageKey: expect.any(String), - value: { - text: "Deliver after reconnect", - mentions: [], - attachments: [], - }, - }), - ); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe("Deliver after reconnect"); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - await act(async () => { - getPromptDraftAccessor({ - kind: "thread", - projectId: "proj-1", - threadId: "thr_child", - }).setDraft({ text: "", mentions: [], attachments: [] }); - saved.resolve(); - }); - const composer = screen.getByTestId( - "embedded-chat-composer", - ); - expect(composer.readOnly).toBe(false); - fireEvent.change(composer, { target: { value: "The next draft" } }); - expect(composer.value).toBe("The next draft"); - }); - - it("retains an offline queued draft when durable persistence fails", async () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - mocks.threadRuntimeDisplayStatus = "active"; - mocks.durableEnqueue.mockRejectedValueOnce( - new Error("Message storage is full"), - ); - renderEmbeddedChat(); - fireEvent.change(screen.getByTestId("embedded-chat-composer"), { - target: { value: "Must stay in the prompt" }, - }); - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Send" })); - }); - const composer = screen.getByTestId( - "embedded-chat-composer", - ); - expect(composer.value).toBe("Must stay in the prompt"); - expect(composer.readOnly).toBe(false); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - }); - it("queues the submitted draft itself while the thread runtime is active", async () => { mocks.threadRuntimeDisplayStatus = "active"; renderEmbeddedChat(); @@ -643,56 +519,37 @@ describe("EmbeddedThreadChat", () => { }), ); expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - await vi.waitFor(() => { - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe(""); - }); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe(""); }); - it.each(["active", "idle"] as const)( - "preserves a pending draft through unmount and network failure when %s", - async (status) => { - const submission = createDeferredPromise(); - const submit = - status === "active" - ? mocks.createQueuedMessageMutateAsync - : mocks.sendThreadMessageMutateAsync; - submit.mockReturnValueOnce(submission.promise); - mocks.threadRuntimeDisplayStatus = status; - const view = renderEmbeddedChat(); - fireEvent.change(screen.getByTestId("embedded-chat-composer"), { - target: { value: "Do not lose this message" }, - }); - fireEvent.click(screen.getByText("Send")); - - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe("Do not lose this message"); - view.unmount(); - await act(async () => { - submission.reject(new TypeError("Failed to fetch")); - }); - renderEmbeddedChat(); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe("Do not lose this message"); - }, - ); - - it("does not resubmit a draft while another composer owns its pending request", () => { - mocks.createQueuedMessageIsPending = true; + it("keeps a queued draft when the request fails after the composer closes", async () => { mocks.threadRuntimeDisplayStatus = "active"; - renderEmbeddedChat(); + const pending = createDeferredPromise(); + mocks.createQueuedMessageMutateAsync.mockReturnValueOnce(pending.promise); + const first = renderEmbeddedChat(); fireEvent.change(screen.getByTestId("embedded-chat-composer"), { - target: { value: "Already submitting" }, + target: { value: "Do not lose this" }, }); fireEvent.click(screen.getByText("Send")); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); expect( screen.getByTestId("embedded-chat-composer").value, - ).toBe("Already submitting"); + ).toBe("Do not lose this"); + first.unmount(); + await act(async () => { + pending.reject(new Error("Connection lost")); + }); + renderEmbeddedChat(); + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe("Do not lose this"); + fireEvent.click(screen.getByText("Send")); + await vi.waitFor(() => { + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe(""); + }); }); it("sends directly when the thread runtime is idle", async () => { diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 48f874ed2a7..d9538cc3f26 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -73,11 +73,6 @@ import { } from "@bb/client-core"; import { useActiveComposerDraft } from "./useActiveComposerDraft"; import { useComposerAttachmentUploads } from "./useComposerAttachmentUploads"; -import { useQueuedMessageRows } from "@/hooks/useQueuedMessageRows"; -import { - isDurableMessageInput, - useDurableMessageSubmission, -} from "@/hooks/useDurableMessageSubmission"; import { useLatestRef } from "@/hooks/useLatestRef"; import { useComposerTypeahead } from "./useComposerTypeahead"; import { useInlineQueuedMessageEditing } from "./useInlineQueuedMessageEditing"; @@ -242,7 +237,7 @@ function EmbeddedThreadChatWithComposer({ const surfaceKey = threadId; const markThreadRead = useMarkThreadRead(); const stopThread = useStopThread(); - const sendThreadMessage = useSendThreadMessage(threadId); + const sendThreadMessage = useSendThreadMessage(); const createQueuedMessage = useCreateThreadQueuedMessage(threadId); const threadQuery = useThread(threadId); const pendingInteractionsQuery = useThreadPendingInteractions(threadId); @@ -266,9 +261,7 @@ function EmbeddedThreadChatWithComposer({ markThreadRead, thread: threadQuery.data, }); - const { data: serverQueuedMessages = [] } = useThreadQueuedMessages(threadId); - const queuedMessages = useQueuedMessageRows(threadId, serverQueuedMessages); - const durableSubmission = useDurableMessageSubmission(threadId); + const { data: queuedMessages = [] } = useThreadQueuedMessages(threadId); const executionOptionsQuery = useThreadDefaultExecutionOptions( composer.executionDefaultsThreadId, @@ -349,7 +342,8 @@ function EmbeddedThreadChatWithComposer({ ); const [composerFocusNonce, setComposerFocusNonce] = useState(0); const [inlineComposerFocusNonce, setInlineComposerFocusNonce] = useState(0); - const [isTurnSubmitting, setIsTurnSubmitting] = useState(false); + const [isSending, setIsTurnSubmitting] = useState(false); + const isTurnSubmitting = isSending || createQueuedMessage.isPending; const isMountedRef = useRef(false); useEffect(() => { isMountedRef.current = true; @@ -498,47 +492,22 @@ function EmbeddedThreadChatWithComposer({ const handleSubmit = useCallback(() => { const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; - if ( - submittedInput.length === 0 || - durableSubmission.isSaving || - (!durableSubmission.connected && - !shouldQueueFollowUpMessage(displayStatus)) || - isTurnSubmitting || - sendThreadMessage.isPending || - createQueuedMessage.isPending - ) { + if (submittedInput.length === 0 || isTurnSubmitting) { return; } + const isQueuingMessage = shouldQueueFollowUpMessage(displayStatus); + if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); setIsTurnSubmitting(true); - const durablyQueued = - durableSubmission.enabled && isDurableMessageInput(submittedInput); - const submission = durablyQueued - ? durableSubmission.enqueue( - shouldQueueFollowUpMessage(displayStatus) - ? { - kind: "queue", - request: { input: submittedInput, ...executionRequestFields }, - } - : { - kind: "send", - request: { - input: submittedInput, - mode: "queue-if-active", - ...executionRequestFields, - }, - }, - { storageKey: promptDraft.storageKey, value: submittedDraft }, - ) - : defaultSendOrQueueInput(submittedInput); - void submission + void defaultSendOrQueueInput(submittedInput) .then(() => { - if (!durablyQueued) promptDraft.clearIfCurrentMatches(submittedDraft); + if (isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); }) .catch((error) => { if (!isMountedRef.current) { return; } + if (!isQueuingMessage) promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error, fallbackMessage: "Failed to send message", @@ -553,15 +522,11 @@ function EmbeddedThreadChatWithComposer({ } }); }, [ - createQueuedMessage.isPending, currentPromptDraft, currentPromptDraftInput, defaultSendOrQueueInput, - durableSubmission, - executionRequestFields, displayStatus, isTurnSubmitting, - sendThreadMessage.isPending, promptDraft, setBottomAttachmentError, ]); @@ -579,19 +544,14 @@ function EmbeddedThreadChatWithComposer({ [isProvisioning, sendQueuedMessageById], ); const hasPromptDraftInput = currentPromptDraftInput.length > 0; - const canSubmitModifierShortcut = - durableSubmission.connected && - canSubmitFollowUpShortcut({ - hasPromptDraftInput, - isFollowUpSubmitting: - durableSubmission.isSaving || - isTurnSubmitting || - sendThreadMessage.isPending, - isQueueMutationPending, - queuedMessageCount: queuedMessages.length, - runtimeDisplayStatus: displayStatus, - submitModeKind: submitMode.kind, - }); + const canSubmitModifierShortcut = canSubmitFollowUpShortcut({ + hasPromptDraftInput, + isFollowUpSubmitting: isTurnSubmitting, + isQueueMutationPending, + queuedMessageCount: queuedMessages.length, + runtimeDisplayStatus: displayStatus, + submitModeKind: submitMode.kind, + }); const handleModifierSubmit = useCallback(() => { if (!canSubmitModifierShortcut) { return; @@ -611,6 +571,7 @@ function EmbeddedThreadChatWithComposer({ return; } + promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); setIsTurnSubmitting(true); void sendThreadMessage @@ -621,13 +582,13 @@ function EmbeddedThreadChatWithComposer({ ...executionRequestFields, }) .then((result) => { - promptDraft.clearIfCurrentMatches(submittedDraft); reportQueuedSendDelivery(result.delivery); }) .catch((error) => { if (!isMountedRef.current) { return; } + promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error, fallbackMessage: "Failed to send message", @@ -842,11 +803,7 @@ function EmbeddedThreadChatWithComposer({ entries: [], onSelectEntry: promptDraft.setDraft, } satisfies HistoryConfig, - isFollowUpSubmitting: - durableSubmission.isSaving || - isTurnSubmitting || - sendThreadMessage.isPending || - createQueuedMessage.isPending, + isFollowUpSubmitting: isTurnSubmitting, message: currentPromptDraft.text, mentionRanges: currentPromptDraft.mentions, onChangeMessage: promptDraft.setTextAndMentions, @@ -855,30 +812,18 @@ function EmbeddedThreadChatWithComposer({ compactPromptPlaceholder: composerPlaceholder, promptPlaceholder: composerPlaceholder, canModifierSubmit: canSubmitModifierShortcut, - steerActiveThreadOnEnter: - durableSubmission.connected && steerActiveThreadOnEnter, - submitDisabled: - !durableSubmission.connected && - !shouldQueueFollowUpMessage(displayStatus), - ...(!durableSubmission.connected && - !shouldQueueFollowUpMessage(displayStatus) - ? { submitTitle: "Waiting for connection" } - : {}), + steerActiveThreadOnEnter, submitMode, threadRuntimeDisplayStatus: displayStatus, }), [ canSubmitModifierShortcut, composerPlaceholder, - createQueuedMessage.isPending, - durableSubmission.connected, - durableSubmission.isSaving, - displayStatus, currentPromptDraft, + displayStatus, handleModifierSubmit, handleSubmit, isTurnSubmitting, - sendThreadMessage.isPending, promptDraft.setDraft, promptDraft.setTextAndMentions, steerActiveThreadOnEnter, @@ -1021,15 +966,11 @@ function EmbeddedThreadChatWithComposer({ ...bottomExecutionConfig, model: { ...bottomExecutionConfig.model, - active: inlineEditingQueuedMessage.model - ? { model: inlineEditingQueuedMessage.model } - : undefined, - selected: inlineEditingQueuedMessage.model ?? "", + active: { model: inlineEditingQueuedMessage.model }, + selected: inlineEditingQueuedMessage.model, }, serviceTier: { - value: - inlineEditingQueuedMessage.serviceTier ?? - bottomExecutionConfig.serviceTier?.value, + value: inlineEditingQueuedMessage.serviceTier, onChange: setServiceTier, supported: supportsServiceTier, supportByProvider: serviceTierSupportByProvider, @@ -1037,9 +978,7 @@ function EmbeddedThreadChatWithComposer({ }, reasoning: { ...bottomExecutionConfig.reasoning, - value: - inlineEditingQueuedMessage.reasoningLevel ?? - bottomExecutionConfig.reasoning.value, + value: inlineEditingQueuedMessage.reasoningLevel, }, } : null, @@ -1141,17 +1080,14 @@ function EmbeddedThreadChatWithComposer({ const queuedMessagesStack = useMemo( () => - (queuedMessages.length > 0 || inlineEditor !== undefined) && - !pendingInteractionOccupiesComposer ? ( + queuedMessages.length > 0 && !pendingInteractionOccupiesComposer ? ( ) : null, [ - durableSubmission.connected, beginEditQueuedMessage, handleDeleteQueuedMessage, handleReorderQueuedMessage, diff --git a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts deleted file mode 100644 index 86c2257b73d..00000000000 --- a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - LocalQueuedMessageRow, - QueuedMessageRow, -} from "@/lib/queued-message-rows"; -import type { SubmissionEditClaim } from "@/lib/message-delivery/store"; -import { useInlineQueuedMessageEditing } from "./useInlineQueuedMessageEditing"; - -const store = vi.hoisted(() => ({ - acquire: vi.fn(), - renew: vi.fn().mockResolvedValue(true), - release: vi.fn().mockResolvedValue(undefined), - toast: vi.fn(), -})); - -vi.mock("@/lib/message-delivery/store", () => ({ - acquireSubmissionEdit: store.acquire, - renewSubmissionEdit: store.renew, - releaseSubmissionEdit: store.release, -})); -vi.mock("@/lib/mutation-errors", () => ({ - showMutationErrorToast: store.toast, -})); - -const row: LocalQueuedMessageRow = { - source: "local", - id: "local-one", - clientSubmissionId: "submission-one", - threadId: "thread-one", - content: [{ type: "text", text: "Original message", mentions: [] }], - model: undefined, - reasoningLevel: undefined, - permissionMode: undefined, - serviceTier: undefined, - createdAt: 1, - updatedAt: 2, - editable: true, - groupWithNext: false, - initiator: "user", - senderThreadId: null, - deliveryStatus: "waiting", - error: null, -}; -const claim: SubmissionEditClaim = { - threadId: row.threadId, - id: row.id, - token: "edit-token", - expiresAt: 45000, -}; - -function renderEditor() { - return renderHook( - ({ messages }: { messages: readonly QueuedMessageRow[] }) => - useInlineQueuedMessageEditing({ - ownerThreadId: row.threadId, - queuedMessages: messages, - }), - { initialProps: { messages: [row] } }, - ); -} - -async function beginEditor(result: ReturnType["result"]) { - await act(async () => { - result.current.beginEditQueuedMessage({ - queuedMessageId: row.id, - queuedMessageIndex: 0, - }); - }); -} - -afterEach(() => { - cleanup(); - vi.useRealTimers(); - vi.clearAllMocks(); -}); - -describe("local queued-message editing claim", () => { - it("acquires the delivery pause before opening and renews it while reconnect updates the row", async () => { - vi.useFakeTimers(); - let resolveClaim: (value: SubmissionEditClaim) => void = () => undefined; - store.acquire.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveClaim = resolve; - }), - ); - const { result, rerender } = renderEditor(); - act(() => - result.current.beginEditQueuedMessage({ - queuedMessageId: row.id, - queuedMessageIndex: 0, - }), - ); - expect(result.current.inlineEditingQueuedMessage).toBeNull(); - await act(async () => { - resolveClaim(claim); - }); - expect(store.acquire).toHaveBeenCalledWith({ - threadId: row.threadId, - id: row.id, - expectedUpdatedAt: row.updatedAt, - }); - act(() => - result.current.queuedMessageDraftSession?.setDraft((draft) => ({ - ...draft, - text: "Unsaved correction", - })), - ); - rerender({ messages: [{ ...row, deliveryStatus: "confirming" }] }); - await act(async () => { - await vi.advanceTimersByTimeAsync(10000); - }); - expect(store.renew).toHaveBeenCalledWith(claim); - expect(result.current.inlineEditingQueuedMessage?.draft.text).toBe( - "Unsaved correction", - ); - act(() => result.current.dismissInlineQueuedMessageEditor()); - expect(store.release).toHaveBeenCalledWith(claim); - expect(result.current.inlineEditingQueuedMessage).toBeNull(); - }); - - it("preserves unsaved corrections when a suspended tab loses its claim and the original leaves the queue", async () => { - vi.useFakeTimers(); - store.acquire.mockResolvedValueOnce(claim); - store.renew.mockResolvedValueOnce(false); - const { result, rerender, unmount } = renderEditor(); - await beginEditor(result); - act(() => - result.current.queuedMessageDraftSession?.setDraft((draft) => ({ - ...draft, - text: "Do not discard this", - })), - ); - await act(async () => { - await vi.advanceTimersByTimeAsync(10000); - }); - rerender({ messages: [] }); - expect(result.current.inlineEditingQueuedMessage?.draft.text).toBe( - "Do not discard this", - ); - expect(store.toast).toHaveBeenCalledOnce(); - unmount(); - expect(store.release).toHaveBeenCalledWith(claim); - }); - - it("does not open an editor when delivery has already claimed the message", async () => { - store.acquire.mockRejectedValueOnce(new Error("Already being delivered")); - const { result } = renderEditor(); - await beginEditor(result); - expect(result.current.inlineEditingQueuedMessage).toBeNull(); - expect(store.toast).toHaveBeenCalledWith( - expect.objectContaining({ - fallbackMessage: "Failed to edit queued message", - }), - ); - }); - - it("releases an acquired claim if the composer unmounts while acquisition is pending", async () => { - let resolveClaim: (value: SubmissionEditClaim) => void = () => undefined; - store.acquire.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveClaim = resolve; - }), - ); - const { result, unmount } = renderEditor(); - act(() => - result.current.beginEditQueuedMessage({ - queuedMessageId: row.id, - queuedMessageIndex: 0, - }), - ); - unmount(); - await act(async () => { - resolveClaim(claim); - }); - expect(store.release).toHaveBeenCalledWith(claim); - }); -}); diff --git a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts index 85abd38b041..5755c1e7f2a 100644 --- a/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts +++ b/apps/app/src/components/thread/embedded-chat/useInlineQueuedMessageEditing.ts @@ -1,15 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - isLocalQueuedMessage, - type QueuedMessageRow, -} from "@/lib/queued-message-rows"; -import { - acquireSubmissionEdit, - releaseSubmissionEdit, - renewSubmissionEdit, - type SubmissionEditClaim, -} from "@/lib/message-delivery/store"; -import { showMutationErrorToast } from "@/lib/mutation-errors"; +import type { ThreadQueuedMessage } from "@bb/domain"; import type { QueuedMessageEditRequest } from "@/components/promptbox/banner/QueuedMessagesList"; import type { PromptDraftState } from "@bb/client-core"; import { queuedInputToDraft } from "@bb/client-core"; @@ -19,19 +9,18 @@ export interface InlineQueuedMessageEditState { draft: PromptDraftState; editSessionId: number; expectedUpdatedAt: number; - localEditClaim?: SubmissionEditClaim; - model: QueuedMessageRow["model"]; + model: ThreadQueuedMessage["model"]; ownerThreadId: string; - permissionMode: QueuedMessageRow["permissionMode"]; + permissionMode: ThreadQueuedMessage["permissionMode"]; queuedMessageId: string; queuedMessageIndex: number; - reasoningLevel: QueuedMessageRow["reasoningLevel"]; - serviceTier: QueuedMessageRow["serviceTier"]; + reasoningLevel: ThreadQueuedMessage["reasoningLevel"]; + serviceTier: ThreadQueuedMessage["serviceTier"]; } interface UseInlineQueuedMessageEditingArgs { ownerThreadId: string; - queuedMessages: readonly QueuedMessageRow[]; + queuedMessages: readonly ThreadQueuedMessage[]; onBeginEdit?: () => void; } @@ -61,15 +50,12 @@ export function useInlineQueuedMessageEditing({ const inlineEditingQueuedMessageRef = useRef(null); const inlineEditSessionIdRef = useRef(0); - const editRequestRef = useRef(0); - const currentOwnerRef = useRef(ownerThreadId); - currentOwnerRef.current = ownerThreadId; - const queuedMessagesByIdRef = useRef>( - new Map(), - ); + const queuedMessagesByIdRef = useRef< + ReadonlyMap + >(new Map()); queuedMessagesByIdRef.current = useMemo(() => { - const next = new Map(); + const next = new Map(); for (const message of queuedMessages) { next.set(message.id, message); } @@ -78,14 +64,6 @@ export function useInlineQueuedMessageEditing({ const commitInlineQueuedMessage = useCallback( (next: InlineQueuedMessageEditState | null) => { - const previousClaim = - inlineEditingQueuedMessageRef.current?.localEditClaim; - if ( - previousClaim && - previousClaim.token !== next?.localEditClaim?.token - ) { - void releaseSubmissionEdit(previousClaim).catch(() => undefined); - } inlineEditingQueuedMessageRef.current = next; setInlineEditingQueuedMessage(next); }, @@ -97,12 +75,15 @@ export function useInlineQueuedMessageEditing({ current: InlineQueuedMessageEditState | null, ) => InlineQueuedMessageEditState | null, ) => { - commitInlineQueuedMessage(updater(inlineEditingQueuedMessageRef.current)); + setInlineEditingQueuedMessage((current) => { + const next = updater(current); + inlineEditingQueuedMessageRef.current = next; + return next; + }); }, - [commitInlineQueuedMessage], + [], ); const dismissInlineQueuedMessageEditor = useCallback(() => { - editRequestRef.current += 1; commitInlineQueuedMessage(null); }, [commitInlineQueuedMessage]); @@ -110,11 +91,10 @@ export function useInlineQueuedMessageEditing({ () => inlineEditingQueuedMessageState !== null && inlineEditingQueuedMessageState.ownerThreadId === ownerThreadId && - (inlineEditingQueuedMessageState.localEditClaim !== undefined || - queuedMessages.some( - (message) => - message.id === inlineEditingQueuedMessageState.queuedMessageId, - )) + queuedMessages.some( + (message) => + message.id === inlineEditingQueuedMessageState.queuedMessageId, + ) ? inlineEditingQueuedMessageState : null, [inlineEditingQueuedMessageState, ownerThreadId, queuedMessages], @@ -132,111 +112,25 @@ export function useInlineQueuedMessageEditing({ inlineEditingQueuedMessageState, ]); - useEffect( - () => () => { - editRequestRef.current += 1; - const claim = inlineEditingQueuedMessageRef.current?.localEditClaim; - if (claim) void releaseSubmissionEdit(claim).catch(() => undefined); - }, - [], - ); - - const localEditClaim = inlineEditingQueuedMessage?.localEditClaim; - useEffect(() => { - if (!localEditClaim) return; - let stopped = false; - let renewing = false; - const reportFailure = (error: Error) => { - if (stopped) return; - stopped = true; - showMutationErrorToast({ - error, - fallbackMessage: - "The queued message is no longer available to edit. Your changes are still here.", - lifecycleOperation: "update_queued_message", - }); - }; - const interval = window.setInterval(() => { - if (stopped || renewing) return; - renewing = true; - void renewSubmissionEdit(localEditClaim) - .then((renewed) => { - if (!renewed) - reportFailure( - new Error( - "The queued message is no longer available to edit. Your changes are still here.", - ), - ); - }) - .catch(() => - reportFailure( - new Error( - "Could not keep this message paused for editing. Your changes are still here.", - ), - ), - ) - .finally(() => { - renewing = false; - }); - }, 10_000); - return () => { - stopped = true; - window.clearInterval(interval); - }; - }, [localEditClaim]); - const beginEditQueuedMessage = useCallback( ({ queuedMessageId, queuedMessageIndex }: QueuedMessageEditRequest) => { const queuedMessage = queuedMessagesByIdRef.current.get(queuedMessageId); - if (!queuedMessage || !queuedMessage.editable) return; - const requestId = ++editRequestRef.current; - const openEditor = (claim?: SubmissionEditClaim) => { - commitInlineQueuedMessage({ - draft: queuedInputToDraft(queuedMessage.content), - editSessionId: (inlineEditSessionIdRef.current += 1), - expectedUpdatedAt: queuedMessage.updatedAt, - localEditClaim: claim, - model: queuedMessage.model, - ownerThreadId, - permissionMode: queuedMessage.permissionMode, - queuedMessageId, - queuedMessageIndex, - reasoningLevel: queuedMessage.reasoningLevel, - serviceTier: queuedMessage.serviceTier, - }); - onBeginEdit?.(); - }; - if (!isLocalQueuedMessage(queuedMessage)) { - openEditor(); + if (!queuedMessage) { return; } - void acquireSubmissionEdit({ - threadId: ownerThreadId, - id: queuedMessageId, + commitInlineQueuedMessage({ + draft: queuedInputToDraft(queuedMessage.content), + editSessionId: (inlineEditSessionIdRef.current += 1), expectedUpdatedAt: queuedMessage.updatedAt, - }) - .then(async (claim) => { - if ( - editRequestRef.current !== requestId || - currentOwnerRef.current !== ownerThreadId - ) { - await releaseSubmissionEdit(claim); - return; - } - openEditor(claim); - }) - .catch((error) => { - if ( - editRequestRef.current !== requestId || - currentOwnerRef.current !== ownerThreadId - ) - return; - showMutationErrorToast({ - error, - fallbackMessage: "Failed to edit queued message", - lifecycleOperation: "update_queued_message", - }); - }); + model: queuedMessage.model, + ownerThreadId, + permissionMode: queuedMessage.permissionMode, + queuedMessageId, + queuedMessageIndex, + reasoningLevel: queuedMessage.reasoningLevel, + serviceTier: queuedMessage.serviceTier, + }); + onBeginEdit?.(); }, [commitInlineQueuedMessage, onBeginEdit, ownerThreadId], ); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts deleted file mode 100644 index 2fbccbde1b4..00000000000 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; -import type { LocalQueuedMessageRow } from "@/lib/queued-message-rows"; -import { useQueuedMessageActions } from "./useQueuedMessageActions"; - -const actions = vi.hoisted(() => ({ - send: vi.fn().mockResolvedValue(undefined), - edit: vi.fn().mockResolvedValue(undefined), - delete: vi.fn().mockResolvedValue(undefined), - reorder: vi.fn().mockResolvedValue(undefined), - group: vi.fn().mockResolvedValue(undefined), - editLocal: vi.fn().mockResolvedValue(undefined), - deleteLocal: vi.fn().mockResolvedValue(undefined), - toast: vi.fn(), -})); - -vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ - useSendThreadQueuedMessage: () => ({ - mutateAsync: actions.send, - isPending: false, - }), - useUpdateThreadQueuedMessage: () => ({ - mutateAsync: actions.edit, - isPending: false, - }), - useDeleteThreadQueuedMessage: () => ({ - mutateAsync: actions.delete, - isPending: false, - }), - useReorderThreadQueuedMessage: () => ({ - mutateAsync: actions.reorder, - isPending: false, - }), - useSetThreadQueuedMessageGroupBoundary: () => ({ - mutateAsync: actions.group, - isPending: false, - }), -})); -vi.mock("@/lib/message-delivery/store", () => ({ - editSubmission: actions.editLocal, - deleteSubmission: actions.deleteLocal, -})); -vi.mock("@/lib/mutation-errors", () => ({ - showMutationErrorToast: actions.toast, -})); - -function localRow(editable: boolean): LocalQueuedMessageRow { - return { - source: "local", - id: "local-one", - clientSubmissionId: "submission-one", - threadId: "thread-one", - content: [{ type: "text", text: "Saved message", mentions: [] }], - model: undefined, - reasoningLevel: undefined, - permissionMode: undefined, - serviceTier: undefined, - createdAt: 1, - updatedAt: 2, - editable, - groupWithNext: false, - initiator: "user", - senderThreadId: null, - deliveryStatus: editable ? "waiting" : "confirming", - error: null, - }; -} - -function renderActions(row: LocalQueuedMessageRow) { - return renderHook(() => - useQueuedMessageActions({ - threadId: row.threadId, - queuedMessages: [makeThreadQueuedMessage({ id: "server-one" }), row], - sendProcessingPersistence: "clear-on-settle", - inlineEditingQueuedMessage: { - draft: { text: "Saved message", mentions: [], attachments: [] }, - editSessionId: 1, - expectedUpdatedAt: row.updatedAt, - localEditClaim: row.editable - ? { - threadId: row.threadId, - id: row.id, - token: "edit-token", - expiresAt: 45000, - } - : undefined, - model: row.model, - ownerThreadId: row.threadId, - permissionMode: row.permissionMode, - queuedMessageId: row.id, - queuedMessageIndex: 1, - reasoningLevel: row.reasoningLevel, - serviceTier: row.serviceTier, - }, - dismissInlineQueuedMessageEditor: vi.fn(), - activeComposerDraftInput: [ - { type: "text", text: "Corrected message", mentions: [] }, - ], - }), - ); -} - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("local queued-message action boundaries", () => { - it("blocks unaccepted sends, uncertain edits and deletes, and mixed reorders", async () => { - const row = localRow(false); - const { result } = renderActions(row); - await act(async () => { - await result.current.sendQueuedMessageById({ - guard: "exists", - messageId: row.id, - mode: "auto", - }); - await result.current.handleSaveInlineQueuedMessage(); - result.current.handleDeleteQueuedMessage(row.id); - result.current.handleReorderQueuedMessage({ - queuedMessageId: "server-one", - previousQueuedMessageId: row.id, - nextQueuedMessageId: null, - }); - result.current.handleSetQueuedMessageGroupBoundary({ - groupBoundaryQueuedMessageId: row.id, - expectedGroupedPrefixQueuedMessageIds: ["server-one", row.id], - }); - }); - for (const action of [ - actions.send, - actions.edit, - actions.delete, - actions.reorder, - actions.group, - actions.editLocal, - actions.deleteLocal, - ]) - expect(action).not.toHaveBeenCalled(); - }); - - it("routes local edits and deletes through transactional storage without server mutations", async () => { - const row = localRow(true); - const { result } = renderActions(row); - await act(async () => { - await result.current.handleSaveInlineQueuedMessage(); - }); - expect(actions.editLocal).toHaveBeenCalledWith({ - threadId: row.threadId, - id: row.id, - expectedUpdatedAt: row.updatedAt, - input: [{ type: "text", text: "Corrected message", mentions: [] }], - editToken: "edit-token", - }); - act(() => result.current.handleDeleteQueuedMessage(row.id)); - await waitFor(() => - expect(result.current.queuedMessageActionPending).toBe(false), - ); - expect(actions.deleteLocal).toHaveBeenCalledWith({ - threadId: row.threadId, - id: row.id, - expectedUpdatedAt: row.updatedAt, - }); - expect(actions.edit).not.toHaveBeenCalled(); - expect(actions.delete).not.toHaveBeenCalled(); - }); - - it("keeps the inline edit available and reports a transaction race rather than falling through to the server", async () => { - actions.editLocal.mockRejectedValueOnce( - new Error("Delivery has already started"), - ); - const { result } = renderActions(localRow(true)); - await act(async () => { - await result.current.handleSaveInlineQueuedMessage(); - }); - expect(actions.toast).toHaveBeenCalledWith( - expect.objectContaining({ - fallbackMessage: "Failed to update queued message", - }), - ); - expect(actions.edit).not.toHaveBeenCalled(); - expect(result.current.isUpdateQueuedMessagePending).toBe(false); - }); -}); diff --git a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts index fe29bfd956a..e55f063080b 100644 --- a/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts +++ b/apps/app/src/components/thread/embedded-chat/useQueuedMessageActions.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import type { PromptInput } from "@bb/domain"; +import type { PromptInput, ThreadQueuedMessage } from "@bb/domain"; import type { SendQueuedMessageMode } from "@bb/server-contract"; import type { QueuedMessageGroupBoundaryRequest, @@ -16,13 +16,8 @@ import { showMutationErrorToast } from "@/lib/mutation-errors"; import type { QueuedMessageReorderRequest } from "@/lib/queued-message-reorder"; import { BbHttpError } from "@/lib/sdk"; import type { InlineQueuedMessageEditState } from "./useInlineQueuedMessageEditing"; -import { - isLocalQueuedMessage, - type QueuedMessageRow, -} from "@/lib/queued-message-rows"; -import { deleteSubmission, editSubmission } from "@/lib/message-delivery/store"; -type QueuedMessageSendGuard = "current-head" | "exists"; +type QueuedMessageSendGuard = "current-head" | "exists" | "none"; interface SendQueuedMessageByIdArgs { guard: QueuedMessageSendGuard; @@ -32,7 +27,7 @@ interface SendQueuedMessageByIdArgs { interface UseQueuedMessageActionsArgs { threadId: string; - queuedMessages: readonly QueuedMessageRow[]; + queuedMessages: readonly ThreadQueuedMessage[]; sendProcessingPersistence: "clear-on-settle" | "until-left-queue"; onSendSuccess?: () => void; onSaveSuccess?: () => void; @@ -77,12 +72,8 @@ export function useQueuedMessageActions({ action: QueuedMessageProcessingAction; id: string; } | null>(null); - const queuedMessagesRef = useRef([]); + const queuedMessagesRef = useRef([]); queuedMessagesRef.current = queuedMessages; - const [localAction, setLocalAction] = useState<"edit" | "delete" | null>( - null, - ); - const localActionPendingRef = useRef(false); const displayedProcessingQueuedMessage = useMemo( () => @@ -99,10 +90,10 @@ export function useQueuedMessageActions({ const sendQueuedMessageById = useCallback( async ({ guard, messageId, mode }: SendQueuedMessageByIdArgs) => { - const message = queuedMessagesRef.current.find( - (row) => row.id === messageId, - ); - if (!message || isLocalQueuedMessage(message)) { + if ( + guard !== "none" && + !queuedMessagesRef.current.some((message) => message.id === messageId) + ) { return; } if ( @@ -146,54 +137,29 @@ export function useQueuedMessageActions({ if ( !inlineEditingQueuedMessage || activeComposerDraftInput.length === 0 || - updateQueuedMessage.isPending || - localActionPendingRef.current + updateQueuedMessage.isPending ) { return; } if ( inlineEditingQueuedMessage.ownerThreadId !== threadId || - (!inlineEditingQueuedMessage.localEditClaim && - !queuedMessagesRef.current.some( - (message) => - message.id === inlineEditingQueuedMessage.queuedMessageId, - )) + !queuedMessagesRef.current.some( + (message) => message.id === inlineEditingQueuedMessage.queuedMessageId, + ) ) { dismissInlineQueuedMessageEditor(); return; } const { expectedUpdatedAt, ownerThreadId, queuedMessageId } = inlineEditingQueuedMessage; - const message = queuedMessagesRef.current.find( - (row) => row.id === queuedMessageId, - ); - const local = - inlineEditingQueuedMessage.localEditClaim !== undefined || - (message !== undefined && isLocalQueuedMessage(message)); - if (local && !inlineEditingQueuedMessage.localEditClaim) return; - if (!local && (!message || !message.editable)) return; - if (local) { - localActionPendingRef.current = true; - setLocalAction("edit"); - } setProcessingQueuedMessage({ id: queuedMessageId, action: "edit" }); try { - if (local) { - await editSubmission({ - threadId: ownerThreadId, - id: queuedMessageId, - expectedUpdatedAt, - input: activeComposerDraftInput, - editToken: inlineEditingQueuedMessage.localEditClaim?.token, - }); - } else { - await updateQueuedMessage.mutateAsync({ - expectedUpdatedAt, - id: ownerThreadId, - input: activeComposerDraftInput, - queuedMessageId, - }); - } + await updateQueuedMessage.mutateAsync({ + expectedUpdatedAt, + id: ownerThreadId, + input: activeComposerDraftInput, + queuedMessageId, + }); onSaveSuccess?.(); dismissInlineQueuedMessageEditor(); } catch (error) { @@ -206,10 +172,6 @@ export function useQueuedMessageActions({ lifecycleOperation: "update_queued_message", }); } finally { - if (local) { - localActionPendingRef.current = false; - setLocalAction(null); - } setProcessingQueuedMessage((current) => current?.id === queuedMessageId ? null : current, ); @@ -225,25 +187,12 @@ export function useQueuedMessageActions({ const handleDeleteQueuedMessage = useCallback( (queuedMessageId: string) => { - const message = queuedMessagesRef.current.find( - (row) => row.id === queuedMessageId, - ); - if (!message || localActionPendingRef.current) return; - const local = isLocalQueuedMessage(message); - if (local && !message.editable) return; - if (local) { - localActionPendingRef.current = true; - setLocalAction("delete"); - } setProcessingQueuedMessage({ id: queuedMessageId, action: "delete" }); - const deletion = local - ? deleteSubmission({ - threadId, - id: queuedMessageId, - expectedUpdatedAt: message.updatedAt, - }) - : deleteQueuedMessage.mutateAsync({ id: threadId, queuedMessageId }); - void deletion + void deleteQueuedMessage + .mutateAsync({ + id: threadId, + queuedMessageId, + }) .catch((error) => { showMutationErrorToast({ error, @@ -252,10 +201,6 @@ export function useQueuedMessageActions({ }); }) .finally(() => { - if (local) { - localActionPendingRef.current = false; - setLocalAction(null); - } setProcessingQueuedMessage((current) => current?.id === queuedMessageId ? null : current, ); @@ -266,20 +211,6 @@ export function useQueuedMessageActions({ const handleReorderQueuedMessage = useCallback( (request: QueuedMessageReorderRequest) => { - if (queuedMessagesRef.current.some(isLocalQueuedMessage)) return; - const ids = [ - request.queuedMessageId, - request.previousQueuedMessageId, - request.nextQueuedMessageId, - request.groupBoundaryQueuedMessageId, - ].filter((id) => id != null); - if ( - ids.some( - (id) => - !queuedMessagesRef.current.some((message) => message.id === id), - ) - ) - return; void reorderQueuedMessage .mutateAsync({ ...request, @@ -298,18 +229,6 @@ export function useQueuedMessageActions({ const handleSetQueuedMessageGroupBoundary = useCallback( (request: QueuedMessageGroupBoundaryRequest) => { - if (queuedMessagesRef.current.some(isLocalQueuedMessage)) return; - const ids = [ - request.groupBoundaryQueuedMessageId, - ...request.expectedGroupedPrefixQueuedMessageIds, - ]; - if ( - ids.some( - (id) => - !queuedMessagesRef.current.some((message) => message.id === id), - ) - ) - return; void setQueuedMessageGroupBoundary .mutateAsync({ id: threadId, @@ -327,7 +246,6 @@ export function useQueuedMessageActions({ ); const queuedMessageActionPending = - localAction !== null || deleteQueuedMessage.isPending || reorderQueuedMessage.isPending || setQueuedMessageGroupBoundary.isPending || @@ -337,8 +255,7 @@ export function useQueuedMessageActions({ return { processingQueuedMessage: displayedProcessingQueuedMessage, queuedMessageActionPending, - isUpdateQueuedMessagePending: - updateQueuedMessage.isPending || localAction === "edit", + isUpdateQueuedMessagePending: updateQueuedMessage.isPending, sendQueuedMessageById, handleSaveInlineQueuedMessage, handleDeleteQueuedMessage, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts index 5eaf1464d60..61a1584aa9b 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts @@ -112,60 +112,6 @@ function makeQueuedMessage( } describe("thread runtime cache owner", () => { - it.each(["send", "queue"] as const)( - "does not resurrect consumed rows when a durable %s receipt is replayed", - (kind) => { - const queryClient = createAppQueryClient({ - defaultOptions: { queries: { gcTime: Infinity, retry: false } }, - showMutationErrorToasts: false, - }); - queryClient.setQueryData(threadQueuedMessagesQueryKey("thread-1"), []); - queryClient.setQueryData(threadPromptHistoryQueryKey("thread-1"), []); - const queuedMessage = makeQueuedMessage({ - id: "consumed-row", - clientSubmissionId: "submission-1", - replayed: true, - }); - if (kind === "queue") { - applyQueuedMessageCreateResult({ - queryClient, - threadId: "thread-1", - queuedMessage, - transaction: undefined, - }); - } else { - applySendThreadMessageSuccess({ - queryClient, - realtimeConnected: false, - request: { - id: "thread-1", - input: queuedMessage.content, - mode: "queue-if-active", - clientSubmissionId: "submission-1", - }, - result: { - ok: true, - delivery: "queued", - queuedMessage, - clientSubmissionId: "submission-1", - replayed: true, - }, - transaction: undefined, - }); - } - expect( - queryClient.getQueryData(threadQueuedMessagesQueryKey("thread-1")), - ).toEqual([]); - expect( - queryClient.getQueryData(threadPromptHistoryQueryKey("thread-1")), - ).toEqual([]); - expect( - queryClient.getQueryState(threadQueuedMessagesQueryKey("thread-1")) - ?.isInvalidated, - ).toBe(true); - }, - ); - it.each([ ["Plan", applyThreadPlanCancellationResult, "activePlanModeCount"], ["Goal", applyThreadGoalClearResult, "activeGoalCount"], diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 6c20abc0489..695a69c1c41 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -884,28 +884,6 @@ export function prefetchThreadQueuedMessages({ }); } -export async function refreshAcceptedSubmissionQueries({ - queryClient, - threadId, - load, -}: PrefetchThreadQueuedMessagesArgs): Promise { - await queryClient.fetchQuery({ - queryKey: threadQueuedMessagesQueryKey(threadId), - queryFn: ({ signal }) => load(signal), - staleTime: 0, - }); - await Promise.all([ - queryClient.refetchQueries( - { queryKey: threadTimelineQueryKeyPrefix(threadId), type: "active" }, - { throwOnError: true, cancelRefetch: false }, - ), - queryClient.refetchQueries( - { queryKey: threadPromptHistoryQueryKey(threadId), type: "active" }, - { throwOnError: true, cancelRefetch: false }, - ), - ]); -} - export function applyCreateThreadResult({ queryClient, request, @@ -1030,14 +1008,6 @@ export function applySendThreadMessageSuccess({ result, transaction, }: ApplySendThreadMessageSuccessArgs): void { - if (result.clientSubmissionId !== undefined) { - invalidateThreadAcceptedMessageQueriesWithoutRealtime({ - queryClient, - threadId: request.id, - }); - invalidateThreadQueueQueries({ queryClient, threadId: request.id }); - return; - } const optimisticCreatedAt = transaction?.optimisticCreatedAt ?? Date.now(); if (result.delivery === "queued") { const optimisticTimelineRowRemoved = @@ -1159,10 +1129,6 @@ export function applyQueuedMessageCreateResult({ threadId, transaction, }: QueuedMessageSuccessArgs): void { - if (queuedMessage.clientSubmissionId !== undefined) { - invalidateThreadQueueQueries({ queryClient, threadId }); - return; - } queryClient.setQueryData( threadQueuedMessagesQueryKey(threadId), (currentQueuedMessages) => { diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx index fce3aa34ae4..b9be11c6f90 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx @@ -453,6 +453,47 @@ describe("thread runtime mutations", () => { ).toEqual([]); }); + it("keeps queue submission pending across remounts and permits retry after failure", async () => { + const { wrapper } = createQueryClientTestHarness(); + const pending = createDeferredPromise(); + vi.mocked(sdk.threads.queuedMessages.create).mockReturnValueOnce( + pending.promise, + ); + const first = renderHook(() => useCreateThreadQueuedMessage("thread-1"), { + wrapper, + }); + const request = { + id: "thread-1", + input: [{ type: "text" as const, text: "Keep me", mentions: [] }], + }; + let sent: Promise; + act(() => { + sent = first.result.current.mutateAsync(request).catch((error) => error); + }); + await waitFor(() => + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), + ); + first.unmount(); + const reopened = renderHook( + () => useCreateThreadQueuedMessage("thread-1"), + { wrapper }, + ); + expect(reopened.result.current.isPending).toBe(true); + await expect(reopened.result.current.mutateAsync(request)).rejects.toThrow( + "still being queued", + ); + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1); + await act(async () => { + pending.reject(new Error("Connection lost")); + await sent; + }); + await waitFor(() => expect(reopened.result.current.isPending).toBe(false)); + await act(async () => { + await reopened.result.current.mutateAsync(request); + }); + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(2); + }); + it("forwards execution input sources and sender thread when queueing a message", async () => { const { wrapper } = createQueryClientTestHarness(); const { result } = renderHook(() => useCreateThreadQueuedMessage(), { @@ -477,68 +518,6 @@ describe("thread runtime mutations", () => { ); }); - it.each([ - ["send", "accepted"], - ["send", "failed"], - ["queue", "accepted"], - ["queue", "failed"], - ] as const)( - "shares a pending %s submission across remounts until it is %s", - async (kind, outcome) => { - const { wrapper } = createQueryClientTestHarness(); - const submission = createDeferredPromise(); - if (kind === "send") { - vi.mocked(sdk.threads.send).mockImplementationOnce(async () => { - await submission.promise; - return { ok: true, delivery: "sent" }; - }); - } else { - vi.mocked(sdk.threads.queuedMessages.create).mockImplementationOnce( - async () => { - await submission.promise; - return makeQueuedMessage(); - }, - ); - } - const useSubmission = - kind === "send" ? useSendThreadMessage : useCreateThreadQueuedMessage; - const sendRequest = - kind === "send" ? sdk.threads.send : sdk.threads.queuedMessages.create; - const first = renderHook(() => useSubmission("thread-1"), { wrapper }); - const request = { - id: "thread-1", - mode: "auto" as const, - input: [ - { type: "text" as const, text: "Keep this draft", mentions: [] }, - ], - }; - act(() => { - void first.result.current.mutateAsync(request).catch(() => undefined); - }); - await waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1)); - first.unmount(); - - const reopened = renderHook(() => useSubmission("thread-1"), { wrapper }); - const unrelated = renderHook(() => useSubmission("thread-2"), { - wrapper, - }); - expect(reopened.result.current.isPending).toBe(true); - expect(unrelated.result.current.isPending).toBe(false); - - await act(async () => { - if (outcome === "accepted") submission.resolve(); - else submission.reject(new TypeError("Failed to fetch")); - }); - await waitFor(() => - expect(reopened.result.current.isPending).toBe(false), - ); - await act(async () => { - await reopened.result.current.mutateAsync(request); - }); - expect(sendRequest).toHaveBeenCalledTimes(2); - }, - ); - it("keeps an optimistically deleted queued message removed when the server says it is already gone", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); queryClient.setQueryData(threadQueuedMessagesQueryKey("thread-1"), [ diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts index 1f17d5b1079..707e1c928b9 100644 --- a/apps/app/src/hooks/mutations/thread-runtime-mutations.ts +++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.ts @@ -166,27 +166,10 @@ export function useCreateThread() { }); } -const sendThreadMessageMutationKey = ["send-thread-message"]; - -export function useSendThreadMessage(threadId?: string) { +export function useSendThreadMessage() { const queryClient = useQueryClient(); - const pendingCount = useIsMutating({ - mutationKey: sendThreadMessageMutationKey, - predicate: ({ state }) => { - const variables = state.variables; - return ( - threadId !== undefined && - typeof variables === "object" && - variables !== null && - "id" in variables && - variables.id === threadId - ); - }, - }); - - const mutation = useMutation({ - mutationKey: sendThreadMessageMutationKey, + return useMutation({ meta: { errorMessage: "Failed to send message.", lifecycleOperation: "send_message", @@ -242,8 +225,6 @@ export function useSendThreadMessage(threadId?: string) { }); }, }); - - return { ...mutation, isPending: mutation.isPending || pendingCount > 0 }; } export function useEditThreadMessage() { @@ -268,26 +249,13 @@ export function useEditThreadMessage() { }); } -const createThreadQueuedMessageMutationKey = ["create-thread-queued-message"]; - export function useCreateThreadQueuedMessage(threadId?: string) { const queryClient = useQueryClient(); - const pendingCount = useIsMutating({ - mutationKey: createThreadQueuedMessageMutationKey, - predicate: ({ state }) => { - const variables = state.variables; - return ( - threadId !== undefined && - typeof variables === "object" && - variables !== null && - "id" in variables && - variables.id === threadId - ); - }, - }); + const mutationKey = ["create-thread-queued-message", threadId]; + const isPending = useIsMutating({ mutationKey, exact: true }) > 0; const mutation = useMutation({ - mutationKey: createThreadQueuedMessageMutationKey, + mutationKey, meta: { errorMessage: "Failed to queue message.", lifecycleOperation: "queue_message", @@ -336,7 +304,19 @@ export function useCreateThreadQueuedMessage(threadId?: string) { }, }); - return { ...mutation, isPending: mutation.isPending || pendingCount > 0 }; + return { + ...mutation, + isPending: threadId === undefined ? mutation.isPending : isPending, + mutateAsync: (...args: Parameters) => { + if ( + threadId !== undefined && + queryClient.isMutating({ mutationKey, exact: true }) + ) { + return Promise.reject(new Error("A message is still being queued.")); + } + return mutation.mutateAsync(...args); + }, + }; } export function useUpdateThreadQueuedMessage() { diff --git a/apps/app/src/hooks/useDurableMessageSubmission.test.tsx b/apps/app/src/hooks/useDurableMessageSubmission.test.tsx deleted file mode 100644 index dcb0a12de31..00000000000 --- a/apps/app/src/hooks/useDurableMessageSubmission.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emptyPromptDraftState } from "@bb/client-core"; -import { useDurableMessageSubmission } from "./useDurableMessageSubmission"; - -const mocks = vi.hoisted(() => ({ - enabled: true, - connected: true, - enqueue: vi.fn(), -})); - -vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemConfig: () => ({ - data: { featureFlags: { durableMessageDelivery: mocks.enabled } }, - }), -})); -vi.mock("@/hooks/useServerConnectionState", () => ({ - useServerConnectionState: () => - mocks.connected ? "connected" : "disconnected", -})); -vi.mock("@/lib/message-delivery/store", () => ({ - enqueueSubmission: mocks.enqueue, -})); - -const input = [ - { type: "text" as const, text: "Keep this message", mentions: [] }, -]; -const draft = { storageKey: "draft-thread-1", value: emptyPromptDraftState() }; - -describe("durable composer submission", () => { - beforeEach(() => { - mocks.enabled = true; - mocks.connected = true; - mocks.enqueue.mockReset(); - }); - afterEach(cleanup); - - it("unlocks after local save while programmatic acceptance is still pending", async () => { - let accept = () => {}; - const acceptance = new Promise((resolve) => { - accept = resolve; - }); - mocks.enqueue.mockResolvedValue({ acceptance }); - const { result } = renderHook(() => - useDurableMessageSubmission("thread-1"), - ); - await act(async () => { - const saved = await result.current.enqueue( - { kind: "send", request: { input, mode: "queue-if-active" } }, - draft, - true, - ); - expect(saved.acceptance).toBe(acceptance); - }); - expect(result.current.isSaving).toBe(false); - expect(mocks.enqueue).toHaveBeenCalledWith( - expect.objectContaining({ - threadId: "thread-1", - draft, - awaitAcceptance: true, - }), - ); - accept(); - await acceptance; - }); - - it("retains the composer after local persistence fails and permits another attempt", async () => { - mocks.enqueue.mockRejectedValueOnce(new Error("Storage is full")); - mocks.enqueue.mockResolvedValueOnce({}); - const { result } = renderHook(() => - useDurableMessageSubmission("thread-1"), - ); - await act(async () => { - await expect( - result.current.enqueue({ kind: "queue", request: { input } }, draft), - ).rejects.toThrow("Storage is full"); - }); - expect(result.current.isSaving).toBe(false); - await act(async () => { - await result.current.enqueue( - { kind: "queue", request: { input } }, - draft, - ); - }); - expect(mocks.enqueue).toHaveBeenCalledTimes(2); - }); - - it("does not persist steering or runtime commands", async () => { - const { result } = renderHook(() => - useDurableMessageSubmission("thread-1"), - ); - await act(async () => { - await expect( - result.current.enqueue( - { kind: "send", request: { input, mode: "steer" } }, - draft, - ), - ).rejects.toThrow("Steering requires"); - await expect( - result.current.enqueue( - { - kind: "queue", - request: { - input: [ - { - type: "text", - text: "/clear", - mentions: [ - { - start: 0, - end: 6, - resource: { - kind: "command", - trigger: "/", - name: "clear", - source: "command", - origin: "builtin", - label: "clear", - argumentHint: null, - }, - }, - ], - }, - ], - }, - }, - draft, - ), - ).rejects.toThrow("does not support offline delivery"); - }); - expect(mocks.enqueue).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/app/src/hooks/useDurableMessageSubmission.ts b/apps/app/src/hooks/useDurableMessageSubmission.ts deleted file mode 100644 index 0241b05deb4..00000000000 --- a/apps/app/src/hooks/useDurableMessageSubmission.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import type { PromptInput } from "@bb/domain"; -import type { PromptDraftState } from "@bb/client-core"; -import type { - CreateQueuedMessageRequest, - SendMessageRequest, -} from "@bb/server-contract"; -import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { useServerConnectionState } from "@/hooks/useServerConnectionState"; -import { - enqueueSubmission, - type SubmissionRequest, -} from "@/lib/message-delivery/store"; -import { notifyComposerSubmitted } from "@/lib/composer-submissions"; - -type DurableRequest = - | { kind: "send"; request: SendMessageRequest } - | { kind: "queue"; request: CreateQueuedMessageRequest }; - -export function isDurableMessageInput(input: readonly PromptInput[]): boolean { - return !input.some( - (block) => - block.type === "text" && - block.mentions.some( - (mention) => - mention.resource.kind === "command" && - mention.resource.source === "command", - ), - ); -} - -export function useDurableMessageSubmission(threadId: string) { - const { data: config } = useSystemConfig(); - const connection = useServerConnectionState(); - const [isSaving, setIsSaving] = useState(false); - const savingRef = useRef(false); - const enabled = config?.featureFlags.durableMessageDelivery === true; - const connected = connection === "connected"; - - const enqueue = useCallback( - async ( - submission: DurableRequest, - draft: { storageKey: string; value: PromptDraftState }, - awaitAcceptance = false, - ) => { - if (savingRef.current) { - throw new Error("A message is still being saved."); - } - if (!enabled || !isDurableMessageInput(submission.request.input)) { - throw new Error("This message does not support offline delivery."); - } - savingRef.current = true; - setIsSaving(true); - try { - let ordinary: SubmissionRequest; - if (submission.kind === "send") { - const mode = submission.request.mode; - if (mode !== "queue-if-active" && mode !== "start") { - throw new Error("Steering requires a server connection."); - } - ordinary = { - kind: "send", - request: { ...submission.request, mode }, - }; - } else { - ordinary = submission; - } - const saved = await enqueueSubmission({ - ...ordinary, - threadId, - draft, - awaitAcceptance, - }); - notifyComposerSubmitted({ kind: "thread", threadId }); - return saved; - } finally { - savingRef.current = false; - setIsSaving(false); - } - }, - [enabled, threadId], - ); - - return { enabled, connected, isSaving, enqueue }; -} diff --git a/apps/app/src/hooks/usePromptDraftStorage.test.tsx b/apps/app/src/hooks/usePromptDraftStorage.test.tsx index 254e4a90ede..cfcaac5d5e1 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.test.tsx +++ b/apps/app/src/hooks/usePromptDraftStorage.test.tsx @@ -4,8 +4,6 @@ import { act, cleanup, render, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getPromptDraftAccessor, - preparePromptDraftSubmission, - completePromptDraftSubmission, usePromptDraftInputThreadIds, usePromptDraftStorage, } from "./usePromptDraftStorage"; @@ -385,124 +383,3 @@ describe("usePromptDraftStorage addQuote", () => { expect(second.result.current.text).toBe("> shared selection\n"); }); }); - -describe("prompt submission handoff", () => { - it("persists a compatible marked draft and cancels stale deferred writes before clearing", () => { - vi.useFakeTimers(); - const scope = uniqueScope(); - const { result } = renderHook(() => usePromptDraftStorage(scope)); - act(() => result.current.setTextAndMentions("Saved submission", [])); - const draft = result.current.getCurrent(); - const key = result.current.storageKey; - act(() => preparePromptDraftSubmission(key, draft, "submission-one")); - expect(JSON.parse(localStorage.getItem(key) ?? "null")).toMatchObject({ - text: draft.text, - mentions: [], - attachments: [], - submissionHandoffId: "submission-one", - }); - expect(result.current.getCurrent()).toEqual(draft); - act(() => - expect(completePromptDraftSubmission(key, draft, "submission-one")).toBe( - "cleared", - ), - ); - act(() => vi.advanceTimersByTime(500)); - expect(localStorage.getItem(key)).toBeNull(); - expect(result.current.text).toBe(""); - }); - - it("preserves a newer identical draft because an edit removes its handoff identity", () => { - vi.useFakeTimers(); - const scope = uniqueScope(); - const { result } = renderHook(() => usePromptDraftStorage(scope)); - act(() => result.current.setTextAndMentions("Repeated text", [])); - const draft = result.current.getCurrent(); - const key = result.current.storageKey; - act(() => preparePromptDraftSubmission(key, draft, "original-submission")); - act(() => result.current.setTextAndMentions("Different text", [])); - act(() => result.current.setTextAndMentions("Repeated text", [])); - expect( - completePromptDraftSubmission(key, draft, "original-submission"), - ).toBe("preserved"); - act(() => vi.advanceTimersByTime(500)); - expect(result.current.text).toBe("Repeated text"); - expect(JSON.parse(localStorage.getItem(key) ?? "null")).not.toHaveProperty( - "submissionHandoffId", - ); - }); - - it("throws on marker persistence failure while keeping the full current draft", () => { - const accessor = getPromptDraftAccessor(uniqueScope()); - const draft = { - text: "Keep my draft", - mentions: [], - attachments: [ - { - type: "localFile" as const, - path: "notes.txt", - name: "notes.txt", - sizeBytes: 12, - }, - ], - }; - accessor.setDraft(draft); - const save = vi - .spyOn(Storage.prototype, "setItem") - .mockImplementation(() => { - throw new DOMException("Quota exceeded", "QuotaExceededError"); - }); - expect(() => - preparePromptDraftSubmission( - accessor.storageKey, - draft, - "unsaved-submission", - ), - ).toThrow("Quota exceeded"); - expect(accessor.getCurrent()).toEqual(draft); - save.mockRestore(); - }); - - it("keeps an interrupted marked draft until its exact committed handoff is recovered", () => { - const accessor = getPromptDraftAccessor(uniqueScope()); - const draft = { text: "Crash recovery", mentions: [], attachments: [] }; - accessor.setDraft(draft); - preparePromptDraftSubmission( - accessor.storageKey, - draft, - "saved-submission", - ); - window.dispatchEvent( - new StorageEvent("storage", { key: accessor.storageKey }), - ); - expect(accessor.getCurrent()).toEqual(draft); - expect( - completePromptDraftSubmission( - accessor.storageKey, - draft, - "different-submission", - ), - ).toBe("preserved"); - const remove = vi - .spyOn(Storage.prototype, "removeItem") - .mockImplementation(() => { - throw new Error("Storage unavailable"); - }); - expect(() => - completePromptDraftSubmission( - accessor.storageKey, - draft, - "saved-submission", - ), - ).toThrow("Storage unavailable"); - expect(accessor.getCurrent()).toEqual(draft); - remove.mockRestore(); - expect( - completePromptDraftSubmission( - accessor.storageKey, - draft, - "saved-submission", - ), - ).toBe("cleared"); - }); -}); diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index d14ea2f9d4a..4662f8e1b9f 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -23,7 +23,6 @@ export type PromptDraftScope = interface PromptDraftCacheEntry { rawValue: string | null; draft: PromptDraftState; - submissionHandoffId?: string; } type PromptDraftListener = () => void; @@ -62,7 +61,6 @@ function readPromptDraft(storageKey: string | null): PromptDraftState { promptDraftCache.set(storageKey, { rawValue, draft, - submissionHandoffId: readSubmissionHandoffId(rawValue), }); return draft; } @@ -96,10 +94,7 @@ function persistPromptDraftCache(storageKey: string): void { return; } - const serialized = serializePromptDraftEnvelope( - cachedEntry.draft, - cachedEntry.submissionHandoffId, - ); + const serialized = serializePromptDraftStorage(cachedEntry.draft); cachedEntry.rawValue = serialized; if (serialized === null) { window.localStorage.removeItem(storageKey); @@ -197,17 +192,11 @@ function writePromptDraft( ): void { if (!storageKey || typeof window === "undefined") return; - const cachedEntry = promptDraftCache.get(storageKey); - const hadHandoff = - (cachedEntry - ? cachedEntry.submissionHandoffId - : readSubmissionHandoffId(readStoredPromptDraftValue(storageKey))) !== - undefined; promptDraftCache.set(storageKey, { rawValue: null, draft: isPromptDraftEmpty(value) ? EMPTY_PROMPT_DRAFT : value, }); - if (options.persist === "deferred" && !hadHandoff) { + if (options.persist === "deferred") { schedulePromptDraftPersist(storageKey); } else { persistPromptDraftCache(storageKey); @@ -215,91 +204,6 @@ function writePromptDraft( emitPromptDraftChange(storageKey); } -function readSubmissionHandoffId(rawValue: string | null): string | undefined { - if (rawValue === null) return undefined; - try { - const value: unknown = JSON.parse(rawValue); - if ( - typeof value === "object" && - value !== null && - "submissionHandoffId" in value && - typeof value.submissionHandoffId === "string" - ) - return value.submissionHandoffId; - } catch { - return undefined; - } - return undefined; -} - -function serializePromptDraftEnvelope( - draft: PromptDraftState, - submissionHandoffId?: string, -): string | null { - if (submissionHandoffId === undefined) - return serializePromptDraftStorage(draft); - return JSON.stringify({ - text: draft.text, - mentions: draft.mentions, - attachments: draft.attachments, - submissionHandoffId, - }); -} - -export function preparePromptDraftSubmission( - storageKey: string, - draft: PromptDraftState, - submissionHandoffId: string, -): void { - if (!arePromptDraftStatesEqual(readPromptDraft(storageKey), draft)) - throw new Error("The draft changed before it could be saved."); - const serialized = serializePromptDraftEnvelope(draft, submissionHandoffId); - if (serialized === null) - throw new Error("An empty draft cannot be submitted."); - clearPromptDraftPersistTimer(storageKey); - pendingPromptDraftStorageKeys.add(storageKey); - window.localStorage.setItem(storageKey, serialized); - if (window.localStorage.getItem(storageKey) !== serialized) - throw new Error( - "The draft could not be saved. Your message has not been sent.", - ); - promptDraftCache.set(storageKey, { - draft, - rawValue: serialized, - submissionHandoffId, - }); - pendingPromptDraftStorageKeys.delete(storageKey); - emitPromptDraftChange(storageKey); -} - -export function completePromptDraftSubmission( - storageKey: string, - draft: PromptDraftState, - submissionHandoffId: string, -): "cleared" | "preserved" { - const currentDraft = readPromptDraft(storageKey); - const rawValue = window.localStorage.getItem(storageKey); - if ( - promptDraftCache.get(storageKey)?.submissionHandoffId !== - submissionHandoffId || - readSubmissionHandoffId(rawValue) !== submissionHandoffId || - !arePromptDraftStatesEqual(currentDraft, draft) || - !arePromptDraftStatesEqual(parsePromptDraftStorage(rawValue), draft) - ) - return "preserved"; - clearPromptDraftPersistTimer(storageKey); - window.localStorage.removeItem(storageKey); - if (window.localStorage.getItem(storageKey) !== null) - throw new Error("The saved draft could not be cleared."); - pendingPromptDraftStorageKeys.delete(storageKey); - promptDraftCache.set(storageKey, { - draft: EMPTY_PROMPT_DRAFT, - rawValue: null, - }); - emitPromptDraftChange(storageKey); - return "cleared"; -} - function restorePromptDraftIfEmpty( storageKey: string | null, value: PromptDraftState, @@ -648,10 +552,3 @@ export function usePromptDraftInputThreadIds( return threadIds; }, [presenceSnapshot, subscriptions]); } - -export function getPromptDraftSubmissionHandoff( - storageKey: string, -): string | undefined { - readPromptDraft(storageKey); - return promptDraftCache.get(storageKey)?.submissionHandoffId; -} diff --git a/apps/app/src/hooks/useQueuedMessageRows.ts b/apps/app/src/hooks/useQueuedMessageRows.ts deleted file mode 100644 index b14e22b4b80..00000000000 --- a/apps/app/src/hooks/useQueuedMessageRows.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useMemo, useSyncExternalStore } from "react"; -import type { ThreadQueuedMessage } from "@bb/domain"; -import { - getSubmissions, - subscribeSubmissions, -} from "@/lib/message-delivery/store"; -import { projectQueuedMessageRows } from "@/lib/queued-message-rows"; -import { useServerConnectionState } from "@/hooks/useServerConnectionState"; - -export function useQueuedMessageRows( - threadId: string, - serverMessages: readonly ThreadQueuedMessage[], -) { - const entries = useSyncExternalStore(subscribeSubmissions, getSubmissions); - const submissions = useMemo( - () => entries.filter((entry) => entry.threadId === threadId), - [entries, threadId], - ); - const connected = useServerConnectionState() === "connected"; - return useMemo( - () => projectQueuedMessageRows({ serverMessages, submissions, connected }), - [serverMessages, submissions, connected], - ); -} diff --git a/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx b/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx deleted file mode 100644 index 8ff315cd2cd..00000000000 --- a/apps/app/src/lib/message-delivery/MessageDeliverySync.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { z } from "zod"; -import { appToast } from "@/components/ui/app-toast"; -import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { invalidateSystemConfig } from "@/hooks/cache-owners/system-cache-effects"; -import { - applyQueuedMessageCreateResult, - applySendThreadMessageSuccess, - refreshAcceptedSubmissionQueries, -} from "@/hooks/cache-owners/thread-runtime-cache-owner"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { wsManager } from "@/lib/ws"; -import { startMessageDelivery } from "./coordinator"; - -const submissionRejectionSchema = z.object({ - details: z.object({ - submission: z.object({ - clientSubmissionId: z.string(), - acceptance: z.literal("rejected"), - }), - }), -}); - -export function MessageDeliverySync() { - const queryClient = useQueryClient(); - const { data: config } = useSystemConfig(); - const enabled = config?.featureFlags.durableMessageDelivery === true; - const errorRef = useRef(null); - - useEffect(() => { - if (!enabled) return; - let deliverySupported = true; - const requireConfirmation = ( - expected: string, - received: string | undefined, - ) => { - if (expected === received) return; - deliverySupported = false; - invalidateSystemConfig({ queryClient }); - const error = new Error( - "Message delivery paused: the server did not confirm the saved submission ID.", - ); - appToast.error(error.message); - throw error; - }; - return startMessageDelivery({ - getAvailability: () => ({ - enabled: deliverySupported, - connected: wsManager.getConnectionState() === "connected", - }), - subscribeAvailability: (listener) => - wsManager.onConnectionStateChange(listener), - deliver: async (entry, signal) => { - if (entry.kind === "send") { - const result = await sdk.threads.send({ - ...entry.request, - threadId: entry.threadId, - signal, - }); - requireConfirmation( - entry.clientSubmissionId, - result.clientSubmissionId, - ); - return { kind: "send", result }; - } - const result = await sdk.threads.queuedMessages.create({ - ...entry.request, - threadId: entry.threadId, - signal, - }); - requireConfirmation( - entry.clientSubmissionId, - result.clientSubmissionId, - ); - return { kind: "queue", result }; - }, - reconcile: async (entry, accepted, signal) => { - if (entry.kind === "send" && accepted.kind === "send") { - applySendThreadMessageSuccess({ - queryClient, - realtimeConnected: false, - request: { ...entry.request, id: entry.threadId }, - result: accepted.result, - transaction: undefined, - }); - } else if (accepted.kind === "queue") { - applyQueuedMessageCreateResult({ - queryClient, - threadId: entry.threadId, - queuedMessage: accepted.result, - transaction: undefined, - }); - } - await refreshAcceptedSubmissionQueries({ - queryClient, - threadId: entry.threadId, - load: () => - sdk.threads.queuedMessages.list({ - threadId: entry.threadId, - signal, - }), - }); - }, - classifyFailure: (error, entry) => { - const message = - error instanceof Error ? error.message : "Message delivery failed."; - if (!(error instanceof BbHttpError)) - return { kind: "unknown", message }; - if ( - error.status === 408 || - error.status === 429 || - error.status >= 500 || - error.code === "queued_message_claim_lost" || - error.code === "queued_message_auto_send_paused" || - error.code === "awaiting_user_interaction" || - (error.status === 409 && error.code !== "client_submission_conflict") - ) - return { kind: "transient", message }; - const rejection = submissionRejectionSchema.safeParse(error.body); - return { - kind: "rejected", - message, - resolvesUncertainty: - rejection.success && - rejection.data.details.submission.clientSubmissionId === - entry.clientSubmissionId, - }; - }, - onError: (error) => { - if (errorRef.current === error.message) return; - errorRef.current = error.message; - appToast.error(error.message); - }, - }); - }, [enabled, queryClient]); - - return null; -} diff --git a/apps/app/src/lib/message-delivery/coordinator.test.ts b/apps/app/src/lib/message-delivery/coordinator.test.ts deleted file mode 100644 index a02f1a071f6..00000000000 --- a/apps/app/src/lib/message-delivery/coordinator.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// @vitest-environment jsdom - -import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { MessageDeliveryOptions } from "./coordinator"; - -let store: typeof import("./store"); -let coordinator: typeof import("./coordinator"); -let stop: (() => void) | undefined; -const accepted = { - kind: "send" as const, - result: { ok: true as const, delivery: "sent" as const }, -}; - -beforeEach(async () => { - vi.resetModules(); - localStorage.clear(); - vi.stubGlobal("indexedDB", new IDBFactory()); - vi.stubGlobal("IDBKeyRange", IDBKeyRange); - store = await import("./store"); - coordinator = await import("./coordinator"); -}); - -afterEach(async () => { - stop?.(); - stop = undefined; - await store.refreshSubmissions(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function enqueue(text: string) { - const { getPromptDraftAccessor } = - await import("@/hooks/usePromptDraftStorage"); - const accessor = getPromptDraftAccessor({ - kind: "thread", - threadId: "thread-one", - projectId: "project-one", - }); - const draft = { text, mentions: [], attachments: [] }; - accessor.setDraft(draft); - return store.enqueueSubmission({ - threadId: "thread-one", - kind: "send", - request: { mode: "queue-if-active", input: [{ type: "text", text }] }, - draft: { storageKey: accessor.storageKey, value: draft }, - }); -} - -function options( - overrides: Partial = {}, -): MessageDeliveryOptions { - return { - getAvailability: () => ({ enabled: true, connected: true }), - subscribeAvailability: () => () => undefined, - deliver: vi.fn().mockResolvedValue(accepted), - reconcile: vi.fn().mockResolvedValue(undefined), - classifyFailure: (error) => ({ - kind: "unknown", - message: error instanceof Error ? error.message : "Disconnected", - }), - ...overrides, - }; -} - -it("retries authoritative reads after acceptance without sending again", async () => { - await enqueue("Accepted message"); - vi.useFakeTimers({ - toFake: [ - "Date", - "setTimeout", - "clearTimeout", - "setInterval", - "clearInterval", - ], - }); - const deliver = vi.fn().mockResolvedValue(accepted); - const reconcile = vi - .fn() - .mockRejectedValueOnce(new TypeError("Read disconnected")) - .mockResolvedValue(undefined); - stop = coordinator.startMessageDelivery(options({ deliver, reconcile })); - await vi.waitFor(() => - expect(store.getSubmissions()[0]).toMatchObject({ - status: "accepted", - error: "Read disconnected", - }), - ); - expect(deliver).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1_100); - await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); - expect(deliver).toHaveBeenCalledTimes(1); - expect(reconcile).toHaveBeenCalledTimes(2); -}); - -it("aborts a hung request and retries its same ID before delivering later work", async () => { - const first = await enqueue("First message"); - const second = await enqueue("Second message"); - vi.useFakeTimers({ - toFake: [ - "Date", - "setTimeout", - "clearTimeout", - "setInterval", - "clearInterval", - ], - }); - const deliver = vi - .fn() - .mockImplementationOnce(() => new Promise(() => undefined)) - .mockResolvedValue(accepted); - const classifyFailure = vi.fn( - (error) => ({ - kind: "unknown", - message: error instanceof Error ? error.message : "Disconnected", - }), - ); - stop = coordinator.startMessageDelivery( - options({ deliver, classifyFailure }), - ); - await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)); - const signal = deliver.mock.calls[0]?.[1]; - await vi.advanceTimersByTimeAsync(30_000); - expect(signal?.aborted).toBe(true); - expect(signal?.reason).toMatchObject({ - name: "TimeoutError", - message: "Message delivery timed out", - }); - await vi.waitFor(() => - expect(classifyFailure).toHaveBeenCalledWith( - expect.objectContaining({ - name: "TimeoutError", - message: "Message delivery timed out", - }), - expect.objectContaining({ clientSubmissionId: first.clientSubmissionId }), - ), - ); - await vi.advanceTimersByTimeAsync(1_100); - await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); - expect(deliver.mock.calls.map(([entry]) => entry.clientSubmissionId)).toEqual( - [ - first.clientSubmissionId, - first.clientSubmissionId, - second.clientSubmissionId, - ], - ); -}); - -it("keeps uncertain prior attempts when a later error cannot establish rejection", async () => { - const first = await enqueue("Uncertain message"); - await enqueue("Must wait"); - const initial = await store.claimSubmission( - first.threadId, - "old-tab", - Date.now(), - 45_000, - ); - if (!("claim" in initial)) throw new Error("Expected a claim"); - await store.settleSubmission(initial.claim, { - kind: "retry", - error: "Response lost", - nextAttemptAt: 0, - }); - const deliver = vi.fn().mockRejectedValue(new Error("Thread archived")); - stop = coordinator.startMessageDelivery( - options({ - deliver, - classifyFailure: () => ({ kind: "rejected", message: "Thread archived" }), - }), - ); - await vi.waitFor(() => - expect(store.getSubmissions()[0]).toMatchObject({ - id: first.id, - status: "uncertain", - error: "Thread archived", - }), - ); - expect(deliver).toHaveBeenCalledTimes(1); - expect(store.getSubmissions()[1]?.attempted).toBe(false); -}); - -it("retains terminal rejection while allowing the next message to proceed", async () => { - const first = await enqueue("Rejected message"); - const second = await enqueue("Allowed message"); - const deliver = vi - .fn() - .mockRejectedValueOnce(new Error("Invalid model")) - .mockResolvedValue(accepted); - stop = coordinator.startMessageDelivery( - options({ - deliver, - classifyFailure: () => ({ kind: "rejected", message: "Invalid model" }), - }), - ); - await vi.waitFor(() => - expect(store.getSubmissions()).toEqual([ - expect.objectContaining({ id: first.id, status: "rejected" }), - ]), - ); - expect(deliver.mock.calls.map(([entry]) => entry.id)).toEqual([ - first.id, - second.id, - ]); -}); - -it("pauses saved submissions until both receipt support and connection return", async () => { - await enqueue("Saved while offline"); - let enabled = false; - let connected = true; - let changed: () => void = () => undefined; - const deliver = vi.fn().mockResolvedValue(accepted); - stop = coordinator.startMessageDelivery( - options({ - deliver, - getAvailability: () => ({ enabled, connected }), - subscribeAvailability: (listener) => { - changed = listener; - return () => undefined; - }, - }), - ); - await store.refreshSubmissions(); - expect(deliver).not.toHaveBeenCalled(); - enabled = true; - connected = false; - changed(); - await store.refreshSubmissions(); - expect(deliver).not.toHaveBeenCalled(); - connected = true; - changed(); - await vi.waitFor(() => expect(store.getSubmissions()).toEqual([])); - expect(deliver).toHaveBeenCalledTimes(1); -}); diff --git a/apps/app/src/lib/message-delivery/coordinator.ts b/apps/app/src/lib/message-delivery/coordinator.ts deleted file mode 100644 index bcb0f1ed97a..00000000000 --- a/apps/app/src/lib/message-delivery/coordinator.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { nanoid } from "nanoid"; -import { calculateExponentialBackoffDelay } from "@bb/domain"; -import { - claimSubmission, - completeSubmissionHandoff, - getSubmissionStorageError, - getSubmissions, - pruneObservedSubmissions, - refreshSubmissions, - renewSubmissionClaim, - settleSubmission, - subscribeSubmissions, - type Submission, - type SubmissionAcceptance, - type SubmissionClaim, -} from "./store"; - -export interface DeliveryFailure { - kind: "transient" | "rejected" | "unknown"; - message: string; - resolvesUncertainty?: boolean; -} - -export interface MessageDeliveryOptions { - getAvailability: () => { enabled: boolean; connected: boolean }; - subscribeAvailability: (listener: () => void) => () => void; - deliver: ( - entry: Submission, - signal: AbortSignal, - ) => Promise; - reconcile: ( - entry: Submission, - accepted: SubmissionAcceptance, - signal: AbortSignal, - ) => Promise; - classifyFailure: (error: unknown, entry: Submission) => DeliveryFailure; - onError?: (error: Error) => void; -} - -const leaseMs = 45_000; -const requestTimeoutMs = 30_000; -const renewIntervalMs = 10_000; -const maxConcurrentThreads = 2; - -function failureError(error: unknown): Error { - return error instanceof Error - ? error - : new Error("Message delivery could not be completed."); -} - -export function startMessageDelivery( - options: MessageDeliveryOptions, -): () => void { - const owner = nanoid(); - const active = new Set(); - const wakeTimes = new Map(); - const controllers = new Set(); - const recovering = new Set(); - let stopped = false; - let scheduled = false; - let timer: ReturnType | undefined; - let lastStorageError: Error | null = null; - - const available = () => { - const state = options.getAvailability(); - return ( - !stopped && state.enabled && state.connected && navigator.onLine !== false - ); - }; - - function scheduleTimer(): void { - if (timer !== undefined) clearTimeout(timer); - const future = [...wakeTimes.values()].filter( - (time) => Number.isFinite(time) && time > Date.now(), - ); - if (future.length === 0 || stopped) { - timer = undefined; - return; - } - timer = setTimeout(kick, Math.max(0, Math.min(...future) - Date.now())); - } - - function kick(): void { - if (scheduled || stopped) return; - scheduled = true; - queueMicrotask(() => { - scheduled = false; - pump(); - }); - } - - async function attempt(claim: SubmissionClaim): Promise { - const controller = new AbortController(); - controllers.add(controller); - let leaseLost = false; - let rejectAbort: (error: unknown) => void = () => undefined; - const aborted = new Promise((_resolve, reject) => { - rejectAbort = reject; - }); - const onAbort = () => - rejectAbort( - controller.signal.reason ?? - new DOMException("Message delivery interrupted", "AbortError"), - ); - controller.signal.addEventListener("abort", onAbort, { once: true }); - const timeout = setTimeout( - () => - controller.abort( - new DOMException("Message delivery timed out", "TimeoutError"), - ), - requestTimeoutMs, - ); - const renewal = setInterval(() => { - void renewSubmissionClaim(claim, Date.now(), leaseMs) - .then((renewed) => { - if (!renewed) { - leaseLost = true; - controller.abort( - new DOMException("Message delivery claim expired", "AbortError"), - ); - } - }) - .catch((error) => { - leaseLost = true; - controller.abort(failureError(error)); - }); - }, renewIntervalMs); - try { - const entry = claim.entry; - if (entry.accepted !== null) { - await Promise.race([ - options.reconcile(entry, entry.accepted, controller.signal), - aborted, - ]); - if (!leaseLost) await settleSubmission(claim, { kind: "reconciled" }); - } else { - const accepted = await Promise.race([ - options.deliver(entry, controller.signal), - aborted, - ]); - if (!leaseLost) - await settleSubmission(claim, { kind: "accepted", accepted }); - } - } catch (error) { - if (leaseLost) { - wakeTimes.set(claim.threadId, Date.now() + leaseMs); - return; - } - const failure = options.classifyFailure(error, claim.entry); - const definitive = - claim.entry.accepted === null && - failure.kind === "rejected" && - (claim.entry.attempts === 1 || failure.resolvesUncertainty === true); - if (definitive) { - await settleSubmission(claim, { - kind: "rejected", - error: failure.message, - }); - options.onError?.(failureError(error)); - } else { - const nextAttemptAt = - Date.now() + - calculateExponentialBackoffDelay({ - attempt: claim.entry.retryCount + 1, - baseDelayMs: 1_000, - maxDelayMs: 60_000, - }); - await settleSubmission(claim, { - kind: "retry", - error: failure.message, - nextAttemptAt, - }); - wakeTimes.set(claim.threadId, nextAttemptAt); - } - } finally { - clearTimeout(timeout); - clearInterval(renewal); - controller.signal.removeEventListener("abort", onAbort); - controllers.delete(controller); - } - } - - async function drain(threadId: string): Promise { - try { - const result = await claimSubmission( - threadId, - owner, - Date.now(), - leaseMs, - ); - if ("claim" in result) { - if (!available()) { - await settleSubmission(result.claim, { - kind: "retry", - error: "Waiting for connection", - nextAttemptAt: Date.now() + 1_000, - unattempted: true, - }); - wakeTimes.set(threadId, Date.now() + 1_000); - return; - } - await attempt(result.claim); - } else if (result.wakeAt !== null) wakeTimes.set(threadId, result.wakeAt); - else wakeTimes.set(threadId, Infinity); - } catch (error) { - options.onError?.(failureError(error)); - wakeTimes.set(threadId, Date.now() + 5_000); - } finally { - active.delete(threadId); - await refreshSubmissions(); - kick(); - } - } - - function pump(): void { - if (stopped) return; - const error = getSubmissionStorageError(); - if (error && error !== lastStorageError) options.onError?.(error); - lastStorageError = error; - const entries = getSubmissions(); - for (const entry of entries) { - if ( - !entry.handoff.completed && - !recovering.has(entry.id) && - (wakeTimes.get(`handoff:${entry.id}`) ?? 0) <= Date.now() - ) { - recovering.add(entry.id); - void completeSubmissionHandoff(entry) - .catch((failure) => { - options.onError?.(failureError(failure)); - wakeTimes.set(`handoff:${entry.id}`, Date.now() + 5_000); - }) - .finally(() => { - recovering.delete(entry.id); - scheduleTimer(); - }); - } - } - if (!available() || error) { - scheduleTimer(); - return; - } - const threads = new Set( - entries - .filter( - (entry) => - entry.status !== "rejected" && - !(entry.status === "accepted" && entry.reconciled), - ) - .map((entry) => entry.threadId), - ); - for (const threadId of threads) { - if (active.size >= maxConcurrentThreads) break; - if (active.has(threadId) || (wakeTimes.get(threadId) ?? 0) > Date.now()) - continue; - active.add(threadId); - void drain(threadId); - } - scheduleTimer(); - } - - function wake(): void { - wakeTimes.clear(); - if (!available()) - for (const controller of controllers) - controller.abort( - new DOMException("Connection unavailable", "AbortError"), - ); - void refreshSubmissions().then(kick); - } - - const unsubscribeStore = subscribeSubmissions(() => { - for (const key of wakeTimes.keys()) - if (!key.startsWith("handoff:")) wakeTimes.delete(key); - kick(); - }); - const unsubscribeAvailability = options.subscribeAvailability(wake); - window.addEventListener("focus", wake); - window.addEventListener("online", wake); - window.addEventListener("offline", wake); - void pruneObservedSubmissions().catch((error) => - options.onError?.(failureError(error)), - ); - wake(); - return () => { - stopped = true; - if (timer !== undefined) clearTimeout(timer); - unsubscribeStore(); - unsubscribeAvailability(); - window.removeEventListener("focus", wake); - window.removeEventListener("online", wake); - window.removeEventListener("offline", wake); - for (const controller of controllers) - controller.abort( - new DOMException("Message delivery paused", "AbortError"), - ); - }; -} diff --git a/apps/app/src/lib/message-delivery/store.test.ts b/apps/app/src/lib/message-delivery/store.test.ts deleted file mode 100644 index adec208ad3b..00000000000 --- a/apps/app/src/lib/message-delivery/store.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -// @vitest-environment jsdom - -import { IDBFactory, IDBKeyRange, IDBObjectStore } from "fake-indexeddb"; -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import type { SubmissionClaim } from "./store"; - -let store: typeof import("./store"); -let drafts: typeof import("@/hooks/usePromptDraftStorage"); -const acceptance = { - kind: "send" as const, - result: { ok: true as const, delivery: "sent" as const }, -}; - -beforeEach(async () => { - vi.resetModules(); - localStorage.clear(); - vi.stubGlobal("indexedDB", new IDBFactory()); - vi.stubGlobal("IDBKeyRange", IDBKeyRange); - store = await import("./store"); - drafts = await import("@/hooks/usePromptDraftStorage"); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function enqueue(text: string, awaitAcceptance = false) { - const accessor = drafts.getPromptDraftAccessor({ - kind: "thread", - threadId: "thread-one", - projectId: "project-one", - }); - const draft = { text, mentions: [], attachments: [] }; - accessor.setDraft(draft); - return store.enqueueSubmission({ - threadId: "thread-one", - kind: "send", - request: { mode: "queue-if-active", input: [{ type: "text", text }] }, - draft: { storageKey: accessor.storageKey, value: draft }, - awaitAcceptance, - }); -} - -async function claim( - owner: string, - now = Date.now(), - leaseMs = 45_000, -): Promise { - const result = await store.claimSubmission("thread-one", owner, now, leaseMs); - if (!("claim" in result)) throw new Error("Expected a submission claim"); - return result.claim; -} - -it("commits distinct identical submissions in order before clearing their drafts", async () => { - const first = await enqueue("Same message"); - const second = await enqueue("Same message"); - await store.refreshSubmissions(); - expect(first.clientSubmissionId).not.toBe(second.clientSubmissionId); - expect(second.sequence).toBe(first.sequence + 1); - expect( - store.getSubmissions().map((entry) => entry.handoff.completed), - ).toEqual([true, true]); - expect(localStorage.getItem(first.handoff.storageKey)).toBeNull(); -}); - -it("rolls back failed enqueue transactions without clearing the marked draft or consuming sequence", async () => { - const add = vi - .spyOn(IDBObjectStore.prototype, "add") - .mockImplementationOnce(() => { - throw new DOMException("Quota exceeded", "QuotaExceededError"); - }); - await expect(enqueue("Keep this draft")).rejects.toThrow("Quota exceeded"); - await store.refreshSubmissions(); - expect(store.getSubmissions()).toEqual([]); - const accessor = drafts.getPromptDraftAccessor({ - kind: "thread", - threadId: "thread-one", - projectId: "project-one", - }); - expect(accessor.getCurrent().text).toBe("Keep this draft"); - add.mockRestore(); - expect((await enqueue("Try again")).sequence).toBe(0); -}); - -it("recovers a committed handoff after draft clearing failed, without losing a newer identical draft", async () => { - const remove = vi - .spyOn(Storage.prototype, "removeItem") - .mockImplementationOnce(() => { - throw new Error("Clear failed"); - }); - const entry = await enqueue("Repeated text"); - remove.mockRestore(); - await store.refreshSubmissions(); - expect(store.getSubmissions()[0]?.handoff.completed).toBe(false); - const accessor = drafts.getPromptDraftAccessor({ - kind: "thread", - threadId: "thread-one", - projectId: "project-one", - }); - accessor.setDraft({ text: "New draft", mentions: [], attachments: [] }); - accessor.setDraft({ text: "Repeated text", mentions: [], attachments: [] }); - await store.completeSubmissionHandoff(entry); - expect(accessor.getCurrent().text).toBe("Repeated text"); - await store.refreshSubmissions(); - expect(store.getSubmissions()[0]?.handoff.completed).toBe(true); -}); - -it("keeps an accepted record until its draft handoff also completes", async () => { - const remove = vi - .spyOn(Storage.prototype, "removeItem") - .mockImplementationOnce(() => { - throw new Error("Clear failed"); - }); - const entry = await enqueue("Accepted before cleanup"); - remove.mockRestore(); - await store.settleSubmission(await claim("owner"), { - kind: "accepted", - accepted: acceptance, - }); - await store.settleSubmission(await claim("owner"), { kind: "reconciled" }); - await store.refreshSubmissions(); - expect(store.getSubmissions()[0]).toMatchObject({ - status: "accepted", - reconciled: true, - handoff: { completed: false }, - }); - await store.completeSubmissionHandoff(entry); - await store.refreshSubmissions(); - expect(store.getSubmissions()).toEqual([]); -}); - -it("atomically chooses one tab and fences expired settlement while retrying the same head", async () => { - const first = await enqueue("First"); - await enqueue("Second"); - const now = Date.now(); - const results = await Promise.all([ - store.claimSubmission("thread-one", "tab-one", now, 10), - store.claimSubmission("thread-one", "tab-two", now, 10), - ]); - expect(results.filter((result) => "claim" in result)).toHaveLength(1); - const winner = results.find((result) => "claim" in result); - if (!winner || !("claim" in winner)) - throw new Error("Expected a winning claim"); - const replacement = await claim("replacement", now + 11, 100); - expect(replacement.entry.clientSubmissionId).toBe(first.clientSubmissionId); - expect( - await store.settleSubmission( - winner.claim, - { kind: "accepted", accepted: acceptance }, - now + 12, - ), - ).toBe(false); - expect( - await store.settleSubmission( - replacement, - { kind: "accepted", accepted: acceptance }, - now + 12, - ), - ).toBe(true); -}); - -it("skips retained rejection and corrects it with a new ID at the delivery tail", async () => { - const first = await enqueue("Rejected"); - const second = await enqueue("Later"); - await store.settleSubmission(await claim("owner"), { - kind: "rejected", - error: "Invalid model", - }); - const later = await claim("owner"); - expect(later.entry.id).toBe(second.id); - await store.settleSubmission(later, { - kind: "retry", - error: "Disconnected", - nextAttemptAt: Date.now() + 10_000, - }); - await store.refreshSubmissions(); - const rejected = store - .getSubmissions() - .find((entry) => entry.id === first.id); - if (!rejected) throw new Error("Rejected message was lost"); - await store.editSubmission({ - threadId: first.threadId, - id: first.id, - expectedUpdatedAt: rejected.updatedAt, - input: [{ type: "text", text: "Corrected", mentions: [] }], - }); - await store.refreshSubmissions(); - const corrected = store - .getSubmissions() - .find((entry) => entry.id === first.id); - expect(corrected?.clientSubmissionId).not.toBe(first.clientSubmissionId); - expect(corrected?.sequence).toBeGreaterThan(second.sequence); - expect(corrected?.createdAt).toBe(first.createdAt); -}); - -it("pauses delivery while an inline edit owns the claim and sends the edited payload", async () => { - const entry = await enqueue("Edit this"); - const edit = await store.acquireSubmissionEdit({ - threadId: entry.threadId, - id: entry.id, - expectedUpdatedAt: entry.updatedAt, - }); - expect( - await store.claimSubmission(entry.threadId, "sender", Date.now(), 45_000), - ).toEqual({ wakeAt: edit.expiresAt }); - await store.editSubmission({ - threadId: entry.threadId, - id: entry.id, - expectedUpdatedAt: entry.updatedAt, - input: [{ type: "text", text: "Updated", mentions: [] }], - editToken: edit.token, - }); - const delivering = await claim("sender"); - expect(delivering.entry.request.input).toEqual([ - { type: "text", text: "Updated", mentions: [] }, - ]); -}); - -it("resolves a programmatic caller after durable acceptance even when another reader completes cleanup", async () => { - const entry = await enqueue("Programmatic message", true); - const accepted = entry.acceptance; - expect(accepted).toBeDefined(); - await store.settleSubmission(await claim("other-tab"), { - kind: "accepted", - accepted: acceptance, - }); - await store.refreshSubmissions(); - await expect(accepted).resolves.toBeUndefined(); - await store.settleSubmission(await claim("other-tab"), { - kind: "reconciled", - }); - await store.refreshSubmissions(); - expect(store.getSubmissions()).toEqual([]); -}); - -it("reopens committed storage with the same transport identity", async () => { - const original = await enqueue("Keep across reload"); - vi.resetModules(); - const reloaded = await import("./store"); - await reloaded.refreshSubmissions(); - expect(reloaded.getSubmissions()[0]).toMatchObject({ - id: original.id, - clientSubmissionId: original.clientSubmissionId, - handoff: { completed: true }, - }); - const next = await reloaded.claimSubmission( - original.threadId, - "reloaded-tab", - Date.now(), - 45_000, - ); - expect(next).toMatchObject({ - claim: { entry: { clientSubmissionId: original.clientSubmissionId } }, - }); -}); - -it("fences an editor after expiry lets a delivery owner claim the saved payload", async () => { - const original = await enqueue("Original payload"); - const edit = await store.acquireSubmissionEdit({ - threadId: original.threadId, - id: original.id, - expectedUpdatedAt: original.updatedAt, - }); - const delivery = await store.claimSubmission( - original.threadId, - "replacement", - edit.expiresAt + 1, - 45_000, - ); - expect(delivery).toMatchObject({ - claim: { entry: { id: original.id, request: original.request } }, - }); - await expect( - store.editSubmission({ - threadId: original.threadId, - id: original.id, - expectedUpdatedAt: original.updatedAt, - input: [{ type: "text", text: "Unsaved new wording", mentions: [] }], - editToken: edit.token, - }), - ).rejects.toThrow("already being delivered"); -}); - -it("leaves a rejected original untouched when its correction transaction fails", async () => { - const original = await enqueue("Original rejected payload"); - await store.settleSubmission(await claim("owner"), { - kind: "rejected", - error: "Invalid model", - }); - await store.refreshSubmissions(); - const rejected = store.getSubmissions()[0]; - if (!rejected) throw new Error("Expected retained rejected message"); - const put = vi - .spyOn(IDBObjectStore.prototype, "put") - .mockImplementationOnce(() => { - throw new DOMException("Quota exceeded", "QuotaExceededError"); - }); - await expect( - store.editSubmission({ - threadId: original.threadId, - id: original.id, - expectedUpdatedAt: rejected.updatedAt, - input: [{ type: "text", text: "Corrected wording", mentions: [] }], - }), - ).rejects.toThrow("Quota exceeded"); - put.mockRestore(); - await store.refreshSubmissions(); - expect(store.getSubmissions()[0]).toEqual(rejected); -}); diff --git a/apps/app/src/lib/message-delivery/store.ts b/apps/app/src/lib/message-delivery/store.ts deleted file mode 100644 index 2502fab6474..00000000000 --- a/apps/app/src/lib/message-delivery/store.ts +++ /dev/null @@ -1,808 +0,0 @@ -import { nanoid } from "nanoid"; -import { z } from "zod"; -import { promptTextMentionSchema, threadQueuedMessageSchema } from "@bb/domain"; -import { - createQueuedMessageRequestSchema, - sendMessageRequestSchema, - sendMessageResponseSchema, - uploadedPromptAttachmentSchema, -} from "@bb/server-contract"; -import { - completePromptDraftSubmission, - preparePromptDraftSubmission, - getPromptDraftSubmissionHandoff, -} from "@/hooks/usePromptDraftStorage"; - -const databaseName = "bb-message-delivery"; -const invalidationKey = "bb.message-delivery.changed"; -const acceptedSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("send"), result: sendMessageResponseSchema }), - z.object({ - kind: z.literal("queue"), - result: threadQueuedMessageSchema, - }), -]); -const draftSchema = z.object({ - text: z.string(), - mentions: z.array(promptTextMentionSchema), - attachments: z.array(uploadedPromptAttachmentSchema), -}); -const submissionFields = { - version: z.literal(1), - id: z.string().min(1), - clientSubmissionId: z.string().min(1).max(128), - threadId: z.string().min(1), - sequence: z.number().int().nonnegative(), - createdAt: z.number().int().nonnegative(), - updatedAt: z.number().int().nonnegative(), - status: z.enum(["pending", "uncertain", "rejected", "accepted"]), - attempted: z.boolean(), - attempts: z.number().int().nonnegative(), - retryCount: z.number().int().nonnegative(), - retainAcceptanceUntil: z.number().nullable(), - acceptanceObserved: z.boolean(), - nextAttemptAt: z.number().nonnegative(), - error: z.string().nullable(), - accepted: acceptedSchema.nullable(), - reconciled: z.boolean(), - handoff: z.object({ - storageKey: z.string(), - draft: draftSchema, - handoffId: z.string(), - completed: z.boolean(), - }), -}; -export const submissionSchema = z - .discriminatedUnion("kind", [ - z.object({ - ...submissionFields, - kind: z.literal("send"), - request: sendMessageRequestSchema.extend({ - clientSubmissionId: z.string().min(1).max(128), - mode: z.enum(["start", "queue-if-active"]), - }), - }), - z.object({ - ...submissionFields, - kind: z.literal("queue"), - request: createQueuedMessageRequestSchema.extend({ - clientSubmissionId: z.string().min(1).max(128), - }), - }), - ]) - .refine( - (entry) => - entry.request.clientSubmissionId === entry.clientSubmissionId && - (entry.accepted === null || entry.accepted.kind === entry.kind), - ); -export type Submission = z.infer; -export type SubmissionAcceptance = z.infer; -export type SubmissionRequest = - | { - kind: "send"; - request: z.input & { - mode: "start" | "queue-if-active"; - }; - } - | { - kind: "queue"; - request: z.input; - }; -export type EnqueueSubmission = SubmissionRequest & { - threadId: string; - draft: { storageKey: string; value: z.infer }; - awaitAcceptance?: boolean; -}; -export type EnqueuedSubmission = Submission & { acceptance?: Promise }; -const claimSchema = z.object({ - purpose: z.enum(["delivery", "edit"]), - owner: z.string(), - token: z.string(), - expiresAt: z.number(), - submissionId: z.string(), -}); -const metadataSchema = z.object({ - threadId: z.string(), - nextSequence: z.number().int().nonnegative(), - claim: claimSchema.nullable(), -}); -export type SubmissionClaim = z.infer & { - threadId: string; - entry: Submission; -}; -export type ClaimResult = - | { claim: SubmissionClaim } - | { wakeAt: number | null }; - -let database: Promise | null = null; -let snapshot: readonly Submission[] = []; -let snapshotText = "[]"; -let storageError: Error | null = null; -const listeners = new Set<() => void>(); -const handoffs = new Map< - string, - { id: string; operation: Promise } ->(); -const acceptanceWaiters = new Map< - string, - { - resolve: () => void; - reject: (error: Error) => void; - timer: ReturnType; - } ->(); -const acceptanceRetentionMs = 24 * 60 * 60 * 1_000; -let observing = false; - -function openDatabase(): Promise { - if (database) return database; - database = new Promise((resolve, reject) => { - const request = indexedDB.open(databaseName, 1); - request.onupgradeneeded = () => { - const entries = request.result.createObjectStore("submissions", { - keyPath: "id", - }); - entries.createIndex("threadSequence", ["threadId", "sequence"], { - unique: true, - }); - request.result.createObjectStore("threads", { keyPath: "threadId" }); - }; - request.onsuccess = () => { - const connection = request.result; - connection.onversionchange = () => { - connection.close(); - database = null; - }; - resolve(connection); - }; - request.onerror = () => { - database = null; - reject( - request.error ?? new Error("Message storage could not be opened."), - ); - }; - request.onblocked = () => { - database = null; - reject(new Error("Message storage is blocked by another app tab.")); - }; - }); - return database; -} - -function readRequest(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => - reject(request.error ?? new Error("Message storage could not be read.")); - }); -} - -async function transact( - mode: IDBTransactionMode, - action: (entries: IDBObjectStore, threads: IDBObjectStore) => Promise, -): Promise { - const connection = await openDatabase(); - const transaction = connection.transaction(["submissions", "threads"], mode, { - durability: "strict", - }); - const done = new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onabort = () => - reject( - transaction.error ?? - new Error("Message storage transaction was aborted."), - ); - transaction.onerror = () => - reject( - transaction.error ?? new Error("Message storage transaction failed."), - ); - }); - try { - const result = await action( - transaction.objectStore("submissions"), - transaction.objectStore("threads"), - ); - await done; - return result; - } catch (error) { - try { - transaction.abort(); - } catch { - await done.catch(() => undefined); - } - await done.catch(() => undefined); - throw error; - } -} - -async function readThread( - entries: IDBObjectStore, - threadId: string, -): Promise { - const raw = await readRequest( - entries - .index("threadSequence") - .getAll( - IDBKeyRange.bound([threadId, 0], [threadId, Number.MAX_SAFE_INTEGER]), - ), - ); - return z.array(submissionSchema).parse(raw); -} - -async function readMetadata(threads: IDBObjectStore, threadId: string) { - const raw = await readRequest(threads.get(threadId)); - return raw === undefined - ? { threadId, nextSequence: 0, claim: null } - : metadataSchema.parse(raw); -} - -function notify(): void { - for (const listener of listeners) listener(); -} - -function changed(): void { - try { - localStorage.setItem(invalidationKey, nanoid()); - } catch { - storageError = new Error( - "Other tabs may not see saved message updates until they reconnect.", - ); - } - notify(); - void refreshSubmissions(); -} - -export async function refreshSubmissions(): Promise { - try { - const entries = await transact("readonly", async (store) => - z.array(submissionSchema).parse(await readRequest(store.getAll())), - ); - entries.sort( - (left, right) => - left.createdAt - right.createdAt || left.id.localeCompare(right.id), - ); - for (const entry of entries) { - const waiter = acceptanceWaiters.get(entry.id); - if ( - !waiter || - (entry.status !== "accepted" && entry.status !== "rejected") - ) - continue; - clearTimeout(waiter.timer); - acceptanceWaiters.delete(entry.id); - if (entry.status === "accepted") { - waiter.resolve(); - void acknowledgeAcceptance(entry.id).catch(() => undefined); - } else waiter.reject(new Error(entry.error ?? "Message was rejected.")); - } - const text = JSON.stringify(entries); - const hadError = storageError !== null; - storageError = null; - if (snapshotText !== text) { - snapshot = entries; - snapshotText = text; - notify(); - } else if (hadError) notify(); - } catch (error) { - storageError = - error instanceof Error - ? error - : new Error("Saved messages could not be read."); - notify(); - } -} - -export function getSubmissions(): readonly Submission[] { - return snapshot; -} -export function getSubmissionStorageError(): Error | null { - return storageError; -} -export function subscribeSubmissions(listener: () => void): () => void { - listeners.add(listener); - if (!observing) { - observing = true; - window.addEventListener("storage", (event) => { - if (event.key === invalidationKey) { - notify(); - void refreshSubmissions(); - } - }); - } - void refreshSubmissions(); - return () => listeners.delete(listener); -} - -export function enqueueSubmission( - input: EnqueueSubmission, -): Promise { - const existing = handoffs.get(input.draft.storageKey); - if ( - existing && - getPromptDraftSubmissionHandoff(input.draft.storageKey) === existing.id - ) - return existing.operation; - const id = nanoid(); - preparePromptDraftSubmission(input.draft.storageKey, input.draft.value, id); - const acceptance = input.awaitAcceptance - ? new Promise((resolve, reject) => { - const timer = setTimeout(() => { - acceptanceWaiters.delete(id); - reject( - new Error( - "Message acceptance could not be confirmed within 24 hours. The saved message has been retained.", - ), - ); - }, acceptanceRetentionMs); - acceptanceWaiters.set(id, { resolve, reject, timer }); - }) - : undefined; - void acceptance?.catch(() => undefined); - const operation = transact("readwrite", async (entries, threads) => { - const metadata = await readMetadata(threads, input.threadId); - const entry = submissionSchema.parse({ - ...input, - request: { ...input.request, clientSubmissionId: id }, - version: 1, - id, - clientSubmissionId: id, - sequence: metadata.nextSequence, - createdAt: Date.now(), - updatedAt: Date.now(), - status: "pending", - attempted: false, - attempts: 0, - retryCount: 0, - retainAcceptanceUntil: input.awaitAcceptance - ? Date.now() + acceptanceRetentionMs - : null, - acceptanceObserved: !input.awaitAcceptance, - nextAttemptAt: 0, - error: null, - accepted: null, - reconciled: false, - handoff: { - storageKey: input.draft.storageKey, - draft: input.draft.value, - handoffId: id, - completed: false, - }, - }); - await readRequest(entries.add(entry)); - await readRequest( - threads.put({ ...metadata, nextSequence: metadata.nextSequence + 1 }), - ); - return entry; - }) - .then(async (entry) => { - changed(); - try { - await completeSubmissionHandoff(entry); - } catch { - changed(); - } - return { ...entry, acceptance }; - }) - .catch((error) => { - const waiter = acceptanceWaiters.get(id); - if (waiter) { - clearTimeout(waiter.timer); - waiter.reject( - error instanceof Error - ? error - : new Error("Message could not be saved."), - ); - acceptanceWaiters.delete(id); - } - throw error; - }); - handoffs.set(input.draft.storageKey, { id, operation }); - void operation - .finally(() => { - if ( - handoffs.get(input.draft.storageKey)?.id === id && - getPromptDraftSubmissionHandoff(input.draft.storageKey) !== id - ) - handoffs.delete(input.draft.storageKey); - }) - .catch(() => { - if (handoffs.get(input.draft.storageKey)?.id === id) - handoffs.delete(input.draft.storageKey); - }); - return operation; -} - -export async function completeSubmissionHandoff( - entry: Submission, -): Promise { - if (entry.handoff.completed) return; - completePromptDraftSubmission( - entry.handoff.storageKey, - entry.handoff.draft, - entry.handoff.handoffId, - ); - await transact("readwrite", async (entries) => { - const raw = await readRequest(entries.get(entry.id)); - if (raw === undefined) return; - const current = submissionSchema.parse(raw); - if (current.handoff.handoffId !== entry.handoff.handoffId) return; - if ( - current.status === "accepted" && - current.reconciled && - (current.acceptanceObserved || - (current.retainAcceptanceUntil ?? Infinity) <= Date.now()) - ) - await readRequest(entries.delete(entry.id)); - else - await readRequest( - entries.put({ - ...current, - handoff: { ...current.handoff, completed: true }, - }), - ); - }); - if ( - handoffs.get(entry.handoff.storageKey)?.id === entry.handoff.handoffId && - getPromptDraftSubmissionHandoff(entry.handoff.storageKey) !== - entry.handoff.handoffId - ) - handoffs.delete(entry.handoff.storageKey); - changed(); -} - -export async function claimSubmission( - threadId: string, - owner: string, - now: number, - leaseMs: number, -): Promise { - const result = await transact( - "readwrite", - async (entries, threads): Promise => { - const metadata = await readMetadata(threads, threadId); - if (metadata.claim && metadata.claim.expiresAt > now) - return { wakeAt: metadata.claim.expiresAt }; - const rows = await readThread(entries, threadId); - const entry = rows.find( - (row) => - row.status !== "rejected" && - !(row.status === "accepted" && row.reconciled), - ); - if (!entry) return { wakeAt: null }; - if (entry.nextAttemptAt > now) return { wakeAt: entry.nextAttemptAt }; - const claim = { - purpose: "delivery" as const, - owner, - token: nanoid(), - submissionId: entry.id, - expiresAt: now + leaseMs, - }; - const attempted = - entry.status === "accepted" - ? entry - : { - ...entry, - attempted: true, - attempts: entry.attempts + 1, - status: "uncertain" as const, - updatedAt: Math.max(Date.now(), entry.updatedAt + 1), - }; - await readRequest(entries.put(attempted)); - await readRequest(threads.put({ ...metadata, claim })); - return { claim: { ...claim, threadId, entry: attempted } }; - }, - ); - if ("claim" in result) changed(); - return result; -} - -export async function renewSubmissionClaim( - claim: SubmissionClaim, - now: number, - leaseMs: number, -): Promise { - return transact("readwrite", async (_entries, threads) => { - const metadata = await readMetadata(threads, claim.threadId); - if ( - metadata.claim?.token !== claim.token || - metadata.claim.expiresAt <= now - ) - return false; - await readRequest( - threads.put({ - ...metadata, - claim: { ...metadata.claim, expiresAt: now + leaseMs }, - }), - ); - return true; - }); -} - -export type SubmissionSettlement = - | { kind: "accepted"; accepted: SubmissionAcceptance } - | { - kind: "retry"; - nextAttemptAt: number; - error: string; - unattempted?: boolean; - } - | { kind: "rejected"; error: string } - | { kind: "reconciled" }; - -export async function settleSubmission( - claim: SubmissionClaim, - settlement: SubmissionSettlement, - now = Date.now(), -): Promise { - const result = await transact("readwrite", async (entries, threads) => { - const metadata = await readMetadata(threads, claim.threadId); - if ( - metadata.claim?.token !== claim.token || - metadata.claim.expiresAt <= now - ) - return false; - const current = submissionSchema.parse( - await readRequest(entries.get(claim.submissionId)), - ); - let next: Submission; - if (settlement.kind === "accepted") - next = submissionSchema.parse({ - ...current, - accepted: settlement.accepted, - status: "accepted", - error: null, - nextAttemptAt: 0, - retryCount: 0, - }); - else if (settlement.kind === "reconciled") - next = { ...current, reconciled: true }; - else if (settlement.kind === "rejected") - next = { ...current, status: "rejected", error: settlement.error }; - else - next = { - ...current, - error: settlement.error, - nextAttemptAt: settlement.nextAttemptAt, - retryCount: current.retryCount + 1, - ...(settlement.unattempted && current.accepted === null - ? { - attempts: current.attempts - 1, - attempted: current.attempts > 1, - status: - current.attempts > 1 - ? ("uncertain" as const) - : ("pending" as const), - } - : {}), - }; - if ( - next.status === "accepted" && - next.reconciled && - next.handoff.completed && - (next.acceptanceObserved || - (next.retainAcceptanceUntil ?? Infinity) <= now) - ) - await readRequest(entries.delete(next.id)); - else - await readRequest( - entries.put({ - ...next, - updatedAt: Math.max(Date.now(), next.updatedAt + 1), - }), - ); - await readRequest(threads.put({ ...metadata, claim: null })); - return true; - }); - if (result) changed(); - return result; -} - -export async function editSubmission(input: { - threadId: string; - id: string; - expectedUpdatedAt: number; - input: Submission["request"]["input"]; - editToken?: string; -}): Promise { - await transact("readwrite", async (entries, threads) => { - const raw = await readRequest(entries.get(input.id)); - if (raw === undefined) - throw new Error( - "This message is no longer available. Your edits have been kept.", - ); - const current = submissionSchema.parse(raw); - const metadata = await readMetadata(threads, input.threadId); - if ( - current.threadId !== input.threadId || - current.updatedAt !== input.expectedUpdatedAt || - (current.attempted && current.status !== "rejected") || - (input.editToken !== undefined && - (metadata.claim?.purpose !== "edit" || - metadata.claim.token !== input.editToken || - metadata.claim.expiresAt <= Date.now())) || - (metadata.claim?.submissionId === current.id && - metadata.claim.token !== input.editToken) - ) - throw new Error( - "This message is already being delivered. Its contents cannot be changed.", - ); - const replacementId = - current.status === "rejected" ? nanoid() : current.clientSubmissionId; - const sequence = - current.status === "rejected" ? metadata.nextSequence : current.sequence; - const next = submissionSchema.parse({ - ...current, - request: { - ...current.request, - input: input.input, - clientSubmissionId: replacementId, - }, - clientSubmissionId: replacementId, - sequence, - updatedAt: Math.max(Date.now(), current.updatedAt + 1), - status: "pending", - attempted: false, - attempts: 0, - retryCount: 0, - error: null, - nextAttemptAt: 0, - accepted: null, - reconciled: false, - acceptanceObserved: true, - retainAcceptanceUntil: null, - }); - await readRequest(entries.put(next)); - await readRequest( - threads.put({ - ...metadata, - nextSequence: - current.status === "rejected" - ? metadata.nextSequence + 1 - : metadata.nextSequence, - claim: - metadata.claim?.token === input.editToken ? null : metadata.claim, - }), - ); - }); - changed(); -} - -export async function deleteSubmission(input: { - threadId: string; - id: string; - expectedUpdatedAt: number; -}): Promise { - await transact("readwrite", async (entries, threads) => { - const current = submissionSchema.parse( - await readRequest(entries.get(input.id)), - ); - const metadata = await readMetadata(threads, input.threadId); - if ( - current.threadId !== input.threadId || - current.updatedAt !== input.expectedUpdatedAt || - (current.attempted && current.status !== "rejected") || - metadata.claim?.submissionId === current.id - ) - throw new Error( - "This message is already being delivered. Wait for confirmation before deleting it.", - ); - await readRequest(entries.delete(current.id)); - }); - changed(); -} - -async function acknowledgeAcceptance(id: string): Promise { - await transact("readwrite", async (entries) => { - const raw = await readRequest(entries.get(id)); - if (raw === undefined) return; - const entry = submissionSchema.parse(raw); - if (entry.status !== "accepted") return; - if (entry.reconciled && entry.handoff.completed) - await readRequest(entries.delete(id)); - else await readRequest(entries.put({ ...entry, acceptanceObserved: true })); - }); - changed(); -} - -export async function pruneObservedSubmissions( - now = Date.now(), -): Promise { - await transact("readwrite", async (entries) => { - const rows = z - .array(submissionSchema) - .parse(await readRequest(entries.getAll())); - for (const entry of rows) { - if ( - entry.status === "accepted" && - entry.reconciled && - entry.handoff.completed && - (entry.acceptanceObserved || - (entry.retainAcceptanceUntil !== null && - entry.retainAcceptanceUntil <= now)) - ) - await readRequest(entries.delete(entry.id)); - } - }); - changed(); -} - -export interface SubmissionEditClaim { - threadId: string; - id: string; - token: string; - expiresAt: number; -} - -export async function acquireSubmissionEdit(input: { - threadId: string; - id: string; - expectedUpdatedAt: number; -}): Promise { - const result = await transact("readwrite", async (entries, threads) => { - const current = submissionSchema.parse( - await readRequest(entries.get(input.id)), - ); - const metadata = await readMetadata(threads, input.threadId); - if ( - current.threadId !== input.threadId || - current.updatedAt !== input.expectedUpdatedAt || - (current.attempted && current.status !== "rejected") || - (metadata.claim && metadata.claim.expiresAt > Date.now()) - ) - throw new Error( - "This message is being delivered. Wait for confirmation before editing it.", - ); - const claim = { - purpose: "edit" as const, - owner: nanoid(), - token: nanoid(), - submissionId: current.id, - expiresAt: Date.now() + 45_000, - }; - await readRequest(threads.put({ ...metadata, claim })); - return { - threadId: input.threadId, - id: current.id, - token: claim.token, - expiresAt: claim.expiresAt, - }; - }); - changed(); - return result; -} - -export async function renewSubmissionEdit( - claim: SubmissionEditClaim, -): Promise { - return transact("readwrite", async (_entries, threads) => { - const metadata = await readMetadata(threads, claim.threadId); - if ( - metadata.claim?.purpose !== "edit" || - metadata.claim.token !== claim.token || - metadata.claim.expiresAt <= Date.now() - ) - return false; - await readRequest( - threads.put({ - ...metadata, - claim: { ...metadata.claim, expiresAt: Date.now() + 45_000 }, - }), - ); - return true; - }); -} - -export async function releaseSubmissionEdit( - claim: SubmissionEditClaim, -): Promise { - await transact("readwrite", async (_entries, threads) => { - const metadata = await readMetadata(threads, claim.threadId); - if ( - metadata.claim?.purpose !== "edit" || - metadata.claim.token !== claim.token - ) - return; - await readRequest(threads.put({ ...metadata, claim: null })); - }); - changed(); -} diff --git a/apps/app/src/lib/queued-message-rows.test.ts b/apps/app/src/lib/queued-message-rows.test.ts deleted file mode 100644 index b157fcd5a9f..00000000000 --- a/apps/app/src/lib/queued-message-rows.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { makeThreadQueuedMessage } from "@bb/test-helpers/domain-fixtures"; -import type { Submission } from "@/lib/message-delivery/store"; -import { projectQueuedMessageRows } from "./queued-message-rows"; - -function submission( - overrides: Partial> = {}, -): Submission { - return { - version: 1, - kind: "queue", - id: "local-one", - clientSubmissionId: "submission-one", - threadId: "thread-one", - sequence: 1, - createdAt: 100, - updatedAt: 100, - status: "pending", - attempted: false, - attempts: 0, - retryCount: 0, - retainAcceptanceUntil: null, - acceptanceObserved: false, - nextAttemptAt: 0, - error: null, - accepted: null, - reconciled: false, - handoff: { - storageKey: "draft-one", - draft: { text: "Preserve this", mentions: [], attachments: [] }, - handoffId: "submission-one", - completed: true, - }, - request: { - input: [{ type: "text", text: "Preserve this", mentions: [] }], - clientSubmissionId: "submission-one", - }, - ...overrides, - }; -} - -describe("durable queue projection", () => { - it("keeps a local row across empty server refetches and replaces it only by submission identity", () => { - const local = submission(); - const unrelated = makeThreadQueuedMessage({ - id: "server-one", - content: local.request.input, - }); - const rows = projectQueuedMessageRows({ - serverMessages: [unrelated], - submissions: [local], - connected: false, - }); - expect(rows.map((row) => row.id)).toEqual(["server-one", "local-one"]); - expect( - projectQueuedMessageRows({ - serverMessages: [], - submissions: [local], - connected: false, - }), - ).toEqual([rows[1]]); - - const accepted = { - ...unrelated, - clientSubmissionId: local.clientSubmissionId, - }; - expect( - projectQueuedMessageRows({ - serverMessages: [accepted], - submissions: [local], - connected: true, - }), - ).toEqual([accepted]); - }); - - it("retains distinct identical messages and keeps corrected rows stable while requests use fresh IDs", () => { - const first = submission(); - const second = submission({ - id: "local-two", - clientSubmissionId: "submission-two", - }); - const corrected = submission({ - clientSubmissionId: "submission-corrected", - sequence: 3, - }); - const rows = projectQueuedMessageRows({ - serverMessages: [], - submissions: [corrected, second], - connected: true, - }); - expect(rows.map((row) => row.id)).toEqual([first.id, second.id]); - expect(rows[0]).toMatchObject({ - clientSubmissionId: "submission-corrected", - source: "local", - }); - }); - - it("freezes attempted rows and keeps definitive errors editable through refresh reconciliation", () => { - const uncertain = submission({ status: "uncertain", attempted: true }); - expect( - projectQueuedMessageRows({ - serverMessages: [], - submissions: [uncertain], - connected: true, - })[0], - ).toMatchObject({ - editable: false, - deliveryStatus: "confirming", - }); - expect( - projectQueuedMessageRows({ - serverMessages: [], - submissions: [ - { - ...uncertain, - status: "rejected", - error: "Attachment no longer exists", - }, - ], - connected: true, - })[0], - ).toMatchObject({ - editable: true, - deliveryStatus: "rejected", - error: "Attachment no longer exists", - }); - expect( - projectQueuedMessageRows({ - serverMessages: [], - submissions: [{ ...uncertain, status: "accepted", reconciled: true }], - connected: true, - }), - ).toEqual([]); - }); -}); diff --git a/apps/app/src/lib/queued-message-rows.ts b/apps/app/src/lib/queued-message-rows.ts deleted file mode 100644 index cb3650374e1..00000000000 --- a/apps/app/src/lib/queued-message-rows.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ThreadQueuedMessage } from "@bb/domain"; -import type { Submission } from "@/lib/message-delivery/store"; - -export interface LocalQueuedMessageRow extends Pick< - ThreadQueuedMessage, - "id" | "threadId" | "content" | "createdAt" | "updatedAt" -> { - model: Submission["request"]["model"]; - reasoningLevel: Submission["request"]["reasoningLevel"]; - permissionMode: Submission["request"]["permissionMode"]; - serviceTier: Submission["request"]["serviceTier"]; - source: "local"; - clientSubmissionId: string; - initiator: "user"; - senderThreadId: null; - groupWithNext: false; - editable: boolean; - deliveryStatus: "waiting" | "confirming" | "rejected"; - error: string | null; -} - -export type QueuedMessageRow = - | (ThreadQueuedMessage & { source?: "server" }) - | LocalQueuedMessageRow; - -export function isLocalQueuedMessage( - message: QueuedMessageRow, -): message is LocalQueuedMessageRow { - return message.source === "local"; -} - -export function getServerQueuedMessages( - messages: readonly QueuedMessageRow[], -): ThreadQueuedMessage[] { - return messages.filter( - (message): message is ThreadQueuedMessage => !isLocalQueuedMessage(message), - ); -} - -export function projectQueuedMessageRows({ - serverMessages, - submissions, - connected, -}: { - serverMessages: readonly ThreadQueuedMessage[]; - submissions: readonly Submission[]; - connected: boolean; -}): QueuedMessageRow[] { - const acceptedIds = new Set( - serverMessages.flatMap((message) => - message.clientSubmissionId ? [message.clientSubmissionId] : [], - ), - ); - const localMessages = submissions - .filter( - (submission) => - !submission.reconciled && - !acceptedIds.has(submission.clientSubmissionId), - ) - .map((submission): LocalQueuedMessageRow => ({ - source: "local", - id: submission.id, - threadId: submission.threadId, - clientSubmissionId: submission.clientSubmissionId, - content: submission.request.input, - model: submission.request.model, - reasoningLevel: submission.request.reasoningLevel, - permissionMode: submission.request.permissionMode, - serviceTier: submission.request.serviceTier, - initiator: "user", - senderThreadId: null, - groupWithNext: false, - createdAt: submission.createdAt, - updatedAt: submission.updatedAt, - editable: - submission.status === "rejected" || - (submission.status === "pending" && !submission.attempted), - deliveryStatus: - submission.status === "rejected" - ? "rejected" - : connected || submission.status === "accepted" - ? "confirming" - : "waiting", - error: submission.status === "rejected" ? submission.error : null, - })); - return [...serverMessages, ...localMessages]; -} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index d62d372ca46..5991fb5ece5 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -29,28 +29,10 @@ import { ThreadDetailPromptArea } from "./ThreadDetailPromptArea"; const mocks = vi.hoisted(() => ({ sendMessageMutateAsync: vi.fn(), - durableEnqueue: vi.fn(), shellProbeRenders: vi.fn(), updateQueuedMessageMutateAsync: vi.fn(), })); -vi.mock("@/hooks/useDurableMessageSubmission", () => ({ - useDurableMessageSubmission: () => ({ - enabled: false, - connected: true, - isSaving: false, - enqueue: mocks.durableEnqueue, - }), - isDurableMessageInput: () => true, -})); - -vi.mock("@/hooks/useQueuedMessageRows", () => ({ - useQueuedMessageRows: ( - _threadId: string, - messages: readonly ThreadQueuedMessage[], - ) => messages, -})); - vi.mock("react-router-dom", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => vi.fn() }; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 411d907393d..9d198372f22 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -51,10 +51,6 @@ import { import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; const mocks = vi.hoisted(() => ({ - createQueuedMessageIsPending: false, - durableEnabled: false, - serverConnected: true, - durableEnqueue: vi.fn(), cancelThreadPlanMutate: vi.fn(), clearThreadGoalMutate: vi.fn(), createQueuedMessageMutateAsync: vi.fn(), @@ -107,21 +103,6 @@ vi.mock("react-router-dom", async (importOriginal) => { }; }); -vi.mock("@/hooks/useDurableMessageSubmission", () => ({ - useDurableMessageSubmission: () => ({ - enabled: mocks.durableEnabled, - connected: mocks.serverConnected, - isSaving: false, - enqueue: mocks.durableEnqueue, - }), - isDurableMessageInput: () => true, -})); - -vi.mock("@/hooks/useQueuedMessageRows", () => ({ - useQueuedMessageRows: (_threadId: string, messages: readonly unknown[]) => - messages, -})); - vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { const { ComposerBannersSlot } = await vi.importActual< typeof import("@/components/plugin/PluginComposerBanners") @@ -153,8 +134,6 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { composer: { canModifierSubmit: boolean; message: string; - submitDisabled?: boolean; - isFollowUpSubmitting?: boolean; onChangeMessage: (message: string, mentions: []) => void; onEscape?: () => void; onModifierSubmit: () => void; @@ -320,17 +299,12 @@ vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { <> composer.onChangeMessage(event.currentTarget.value, []) } /> - {composer.canModifierSubmit ? ( @@ -693,7 +667,7 @@ vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ mutateAsync: mocks.createThreadMutateAsync, }), useCreateThreadQueuedMessage: () => ({ - isPending: mocks.createQueuedMessageIsPending, + isPending: false, mutateAsync: mocks.createQueuedMessageMutateAsync, }), useDeleteThreadQueuedMessage: () => ({ @@ -927,10 +901,6 @@ function renderPromptArea(options: RenderPromptAreaOptions = {}) { } beforeEach(() => { - mocks.createQueuedMessageIsPending = false; - mocks.durableEnabled = false; - mocks.serverConnected = true; - mocks.durableEnqueue.mockReset().mockResolvedValue(undefined); testQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -992,192 +962,6 @@ describe("environment follow-up summary", () => { }); describe("ThreadDetailPromptArea", () => { - it("disables idle Send while disconnected and keeps the draft editable", () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - mocks.promptDraft.text = "An offline draft"; - renderPromptArea({ - thread: makeThread({ - status: "idle", - runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, - }), - }); - const composer = screen.getByRole("textbox", { - name: "Composer message", - }); - expect( - screen.getByRole("button", { name: "Submit composer" }) - .disabled, - ).toBe(true); - expect(composer.readOnly).toBe(false); - expect(screen.getByTestId("submit-title").textContent).toBe( - "Waiting for connection", - ); - fireEvent.change(composer, { target: { value: "Still writing offline" } }); - expect(mocks.promptDraft.setTextAndMentions).toHaveBeenCalledWith( - "Still writing offline", - [], - ); - expect(mocks.durableEnqueue).not.toHaveBeenCalled(); - expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); - }); - - it("saves an offline busy-thread message through durable delivery without a second mutation or draft clear", async () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - mocks.promptDraft.text = "Save before reconnect"; - const saved = createDeferredPromise(); - mocks.durableEnqueue.mockReturnValueOnce(saved.promise); - renderPromptArea({ - thread: makeThread({ - status: "active", - runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, - }), - }); - fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - expect(mocks.durableEnqueue).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "queue", - request: expect.objectContaining({ - input: [ - { type: "text", text: "Save before reconnect", mentions: [] }, - ], - }), - }), - { - storageKey: mocks.promptDraft.storageKey, - value: { text: "Save before reconnect", mentions: [], attachments: [] }, - }, - ); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - await act(async () => { - saved.resolve(); - }); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); - const composer = screen.getByRole("textbox", { - name: "Composer message", - }); - expect(composer.readOnly).toBe(false); - fireEvent.change(composer, { target: { value: "Another draft" } }); - expect(mocks.promptDraft.setTextAndMentions).toHaveBeenCalledWith( - "Another draft", - [], - ); - }); - - it("keeps the prompt intact if durable queue persistence fails", async () => { - mocks.durableEnabled = true; - mocks.serverConnected = false; - mocks.promptDraft.text = "Keep this unsaved submission"; - mocks.durableEnqueue.mockRejectedValueOnce( - new Error("Message storage is full"), - ); - renderPromptArea({ - thread: makeThread({ - status: "active", - runtime: { displayStatus: "active", hostReconnectGraceExpiresAt: null }, - }), - }); - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - }); - expect( - screen.getByRole("textbox", { - name: "Composer message", - }).value, - ).toBe("Keep this unsaved submission"); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); - }); - - it("does not resubmit a draft while another composer owns its pending request", () => { - mocks.createQueuedMessageIsPending = true; - mocks.promptDraft.text = "Already submitting"; - renderPromptArea(); - fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - expect(mocks.createQueuedMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.sendMessageMutateAsync).not.toHaveBeenCalled(); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - }); - - it.each(["accepted", "failed"])( - "retains a queued draft until the request is %s", - async (outcome) => { - const submission = createDeferredPromise(); - mocks.createQueuedMessageMutateAsync.mockReturnValueOnce( - submission.promise, - ); - mocks.promptDraft.text = "Keep this message safe"; - const submittedDraft = mocks.promptDraft.getCurrent(); - renderPromptArea({ - thread: makeThread({ - status: "active", - runtime: { - displayStatus: "active", - hostReconnectGraceExpiresAt: null, - }, - }), - }); - - fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - - expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledTimes(1); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - - await act(async () => { - if (outcome === "accepted") { - submission.resolve(makeQueuedMessage()); - } else { - submission.reject(new TypeError("Failed to fetch")); - } - }); - - if (outcome === "accepted") { - expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledWith( - submittedDraft, - ); - } else { - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - } - }, - ); - - it.each(["accepted", "failed"])( - "retains a direct-send draft until the request is %s", - async (outcome) => { - const submission = createDeferredPromise<{ - ok: true; - delivery: "sent"; - }>(); - mocks.sendMessageMutateAsync.mockReturnValueOnce(submission.promise); - mocks.promptDraft.text = "Keep this direct message safe"; - const submittedDraft = mocks.promptDraft.getCurrent(); - renderPromptArea({ - thread: makeThread({ - status: "idle", - runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, - }), - }); - fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); - expect(mocks.sendMessageMutateAsync).toHaveBeenCalledTimes(1); - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - await act(async () => { - if (outcome === "accepted") - submission.resolve({ ok: true, delivery: "sent" }); - else submission.reject(new TypeError("Failed to fetch")); - }); - if (outcome === "accepted") - expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledWith( - submittedDraft, - ); - else - expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); - }, - ); - it("preserves plugin submission data through a follow-up composer", async () => { mocks.defaultExecutionOptions = { model: "gpt-5", @@ -1367,6 +1151,37 @@ describe("ThreadDetailPromptArea", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it.each(["accepted", "failed"])( + "retains the queued draft until the request is %s", + async (outcome) => { + mocks.promptDraft.text = "Keep this queued prompt"; + const pending = createDeferredPromise(); + mocks.createQueuedMessageMutateAsync.mockReturnValueOnce(pending.promise); + renderPromptArea({ + thread: makeThread({ + status: "active", + runtime: { + displayStatus: "active", + hostReconnectGraceExpiresAt: null, + }, + }), + }); + fireEvent.click(screen.getByRole("button", { name: "Submit composer" })); + await waitFor(() => + expect(mocks.createQueuedMessageMutateAsync).toHaveBeenCalledTimes(1), + ); + expect(mocks.promptDraft.clearIfCurrentMatches).not.toHaveBeenCalled(); + await act(async () => { + if (outcome === "accepted") pending.resolve(makeQueuedMessage()); + else pending.reject(new Error("Connection lost")); + }); + expect(mocks.promptDraft.clearIfCurrentMatches).toHaveBeenCalledTimes( + outcome === "accepted" ? 1 : 0, + ); + expect(mocks.promptDraft.restoreIfEmpty).not.toHaveBeenCalled(); + }, + ); + it("keeps the queued drawer adjacent to the bottom composer", () => { mocks.queuedMessages = [makeQueuedMessage()]; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 72e3e7d90b5..100cac9d6f7 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -86,12 +86,6 @@ import type { MachineLabelHost } from "@/components/machines/MachineLabel"; import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; -import { useQueuedMessageRows } from "@/hooks/useQueuedMessageRows"; -import { getServerQueuedMessages } from "@/lib/queued-message-rows"; -import { - isDurableMessageInput, - useDurableMessageSubmission, -} from "@/hooks/useDurableMessageSubmission"; import { useLatestRef } from "@/hooks/useLatestRef"; import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; import { useProjectDisplayName } from "@/hooks/queries/sidebar-navigation-query"; @@ -460,15 +454,11 @@ export function ThreadDetailPromptArea({ const queuedMessagesQuery = useThreadQueuedMessages(thread.id, { enabled: true, }); - const serverQueuedMessages = - queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES; - const queuedMessages = useQueuedMessageRows(thread.id, serverQueuedMessages); - const durableSubmission = useDurableMessageSubmission(thread.id); + const queuedMessages = queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES; const queuedMessagesPending = - queuedMessagesQuery.data === undefined && - queuedMessageCount > 0 && - queuedMessages.length === 0; - const queuedMessagesRef = useLatestRef(queuedMessages); + queuedMessagesQuery.data === undefined && queuedMessageCount > 0; + const queuedMessagesRef = + useLatestRef(queuedMessages); const [bottomPluginFocusNonce, setBottomPluginFocusNonce] = useState(0); const [editFocusNonce, setEditFocusNonce] = useState(0); const focusBottomPluginComposer = useCallback(() => { @@ -909,7 +899,6 @@ export function ThreadDetailPromptArea({ queuedMessageActionPending || isFollowUpShortcutSending; const isFollowUpSubmitting = - durableSubmission.isSaving || sendMessage.isPending || createQueuedMessage.isPending || createThread.isPending || @@ -1090,16 +1079,14 @@ export function ThreadDetailPromptArea({ ], ); const hasPromptDraftInput = currentPromptDraftInput.length > 0; - const canSubmitModifierShortcut = - durableSubmission.connected && - canSubmitFollowUpShortcut({ - hasPromptDraftInput, - isFollowUpSubmitting, - isQueueMutationPending, - queuedMessageCount: queuedMessages.length, - runtimeDisplayStatus, - submitModeKind: submitMode.kind, - }); + const canSubmitModifierShortcut = canSubmitFollowUpShortcut({ + hasPromptDraftInput, + isFollowUpSubmitting, + isQueueMutationPending, + queuedMessageCount: queuedMessages.length, + runtimeDisplayStatus, + submitModeKind: submitMode.kind, + }); const followUpExecutionSelection = useMemo(() => { if (!hasConcreteDefaultExecutionOptions) { return null; @@ -1148,15 +1135,23 @@ export function ThreadDetailPromptArea({ ...baseRequest, ...(pluginSubmission === undefined ? {} : { pluginSubmission }), }; + const clearedSubmittedDraft = + promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); - const created = await createThread.mutateAsync(request); - promptDraft.clearIfCurrentMatches(submittedDraft); - navigate( - getThreadRoutePath({ - projectId: created.projectId, - threadId: created.id, - }), - ); + try { + const created = await createThread.mutateAsync(request); + navigate( + getThreadRoutePath({ + projectId: created.projectId, + threadId: created.id, + }), + ); + } catch (error) { + if (clearedSubmittedDraft) { + promptDraft.restoreIfEmpty(submittedDraft); + } + throw error; + } return true; }, [ @@ -1175,13 +1170,7 @@ export function ThreadDetailPromptArea({ ); const handleSend = useCallback(async () => { - if ( - isFollowUpSubmitting || - (!durableSubmission.connected && - !shouldQueueFollowUpMessage(runtimeDisplayStatus)) - ) { - return; - } + if (isFollowUpSubmitting) return; const submittedDraft = currentPromptDraft; const submittedInput = currentPromptDraftInput; if (isHandoffSelection) { @@ -1198,6 +1187,7 @@ export function ThreadDetailPromptArea({ return; } + if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); try { @@ -1208,19 +1198,8 @@ export function ThreadDetailPromptArea({ execution: followUpExecutionSelection, }); if (request) { - if ( - durableSubmission.enabled && - isDurableMessageInput(submittedInput) - ) { - const { id: _threadId, ...payload } = request; - await durableSubmission.enqueue( - { kind: "queue", request: payload }, - { storageKey: promptDraft.storageKey, value: submittedDraft }, - ); - } else { - await createQueuedMessage.mutateAsync(request); - promptDraft.clearIfCurrentMatches(submittedDraft); - } + await createQueuedMessage.mutateAsync(request); + promptDraft.clearIfCurrentMatches(submittedDraft); } } else { const request = buildAutoFollowUpRequest({ @@ -1229,22 +1208,11 @@ export function ThreadDetailPromptArea({ execution: followUpExecutionSelection, }); if (request) { - if ( - durableSubmission.enabled && - isDurableMessageInput(submittedInput) - ) { - const { id: _threadId, ...payload } = request; - await durableSubmission.enqueue( - { kind: "send", request: payload }, - { storageKey: promptDraft.storageKey, value: submittedDraft }, - ); - } else { - await sendMessage.mutateAsync(request); - promptDraft.clearIfCurrentMatches(submittedDraft); - } + await sendMessage.mutateAsync(request); } } } catch (nextError) { + if (!isQueuingMessage) promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error: nextError, fallbackMessage: isQueuingMessage @@ -1256,7 +1224,6 @@ export function ThreadDetailPromptArea({ }, [ createHandoffThread, createQueuedMessage, - durableSubmission, currentPromptDraft, currentPromptDraftInput, followUpExecutionSelection, @@ -1274,15 +1241,6 @@ export function ThreadDetailPromptArea({ submitOptions: ExperimentalComposerSubmitOptions, pluginSubmission: SendMessageRequest["pluginSubmission"], ) => { - if ( - !durableSubmission.connected && - !shouldQueueFollowUpMessage(runtimeDisplayStatus) - ) { - throw new Error("Waiting for connection."); - } - if (isFollowUpSubmitting) { - throw new Error("A message is still being submitted."); - } if (isHandoffSelection) { if (effectiveSelectedModel.length === 0) { throw new Error("The selected model is still loading."); @@ -1320,28 +1278,21 @@ export function ThreadDetailPromptArea({ if (request === null) { throw new Error("Type a message before submitting it."); } + const clearedSubmittedDraft = + promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); try { - const submissionRequest = { + await sendMessage.mutateAsync({ ...request, ...(submitOptions.sendAt === undefined ? {} : { sendAt: submitOptions.sendAt }), ...(pluginSubmission === undefined ? {} : { pluginSubmission }), - }; - if (durableSubmission.enabled && isDurableMessageInput(request.input)) { - const { id: _threadId, ...payload } = submissionRequest; - const saved = await durableSubmission.enqueue( - { kind: "send", request: payload }, - { storageKey: promptDraft.storageKey, value: submittedDraft }, - true, - ); - await saved.acceptance; - } else { - await sendMessage.mutateAsync(submissionRequest); - promptDraft.clearIfCurrentMatches(submittedDraft); - } + }); } catch (scheduleError) { + if (clearedSubmittedDraft) { + promptDraft.restoreIfEmpty(submittedDraft); + } throw new Error( getMutationErrorMessage({ error: scheduleError, @@ -1353,12 +1304,9 @@ export function ThreadDetailPromptArea({ }, [ createHandoffThread, - durableSubmission, - runtimeDisplayStatus, effectiveSelectedModel, followUpExecutionSelection, isDefaultExecutionOptionsLoading, - isFollowUpSubmitting, isHandoffSelection, promptDraft, sendMessage, @@ -1380,7 +1328,7 @@ export function ThreadDetailPromptArea({ const shortcutRequest = buildFollowUpShortcutRequest({ execution: followUpExecutionSelection, input: submittedInput, - queuedMessages: getServerQueuedMessages(queuedMessagesRef.current), + queuedMessages: queuedMessagesRef.current, threadId: thread.id, }); if (!shortcutRequest) { @@ -1388,14 +1336,15 @@ export function ThreadDetailPromptArea({ } if (shortcutRequest.kind === "draft") { + promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); await runWhileFollowUpShortcutSending( setIsFollowUpShortcutSending, async () => { try { await sendMessage.mutateAsync(shortcutRequest.request); - promptDraft.clearIfCurrentMatches(submittedDraft); } catch (nextError) { + promptDraft.restoreIfEmpty(submittedDraft); showMutationErrorToast({ error: nextError, fallbackMessage: "Failed to send message", @@ -1513,21 +1462,12 @@ export function ThreadDetailPromptArea({ compactPromptPlaceholder, promptPlaceholder, canModifierSubmit: canSubmitModifierShortcut, - steerActiveThreadOnEnter: - durableSubmission.connected && steerActiveThreadOnEnter, - submitDisabled: - !durableSubmission.connected && - !shouldQueueFollowUpMessage(runtimeDisplayStatus), - ...(!durableSubmission.connected && - !shouldQueueFollowUpMessage(runtimeDisplayStatus) - ? { submitTitle: "Waiting for connection" } - : {}), + steerActiveThreadOnEnter, submitMode, threadRuntimeDisplayStatus: runtimeDisplayStatus, }), [ canSubmitModifierShortcut, - durableSubmission.connected, compactPromptPlaceholder, currentPromptDraft, handleBottomComposerModifierSubmit, @@ -1676,22 +1616,16 @@ export function ThreadDetailPromptArea({ ...compactExecutionConfig, model: { ...compactExecutionConfig.model, - active: inlineEditingQueuedMessage.model - ? { model: inlineEditingQueuedMessage.model } - : undefined, - selected: inlineEditingQueuedMessage.model ?? "", + active: { model: inlineEditingQueuedMessage.model }, + selected: inlineEditingQueuedMessage.model, }, serviceTier: { ...compactExecutionConfig.serviceTier, - value: - inlineEditingQueuedMessage.serviceTier ?? - compactExecutionConfig.serviceTier?.value, + value: inlineEditingQueuedMessage.serviceTier, }, reasoning: { ...compactExecutionConfig.reasoning, - value: - inlineEditingQueuedMessage.reasoningLevel ?? - compactExecutionConfig.reasoning.value, + value: inlineEditingQueuedMessage.reasoningLevel, }, }; }, [compactExecutionConfig, inlineEditingQueuedMessage]); @@ -2107,7 +2041,6 @@ export function ThreadDetailPromptArea({ inlineEditor={queuedMessageEditor ?? undefined} sendAction={shouldSteerWhenReady ? "steer-when-ready" : "send-now"} sendDisabled={ - !durableSubmission.connected || submitMode.kind === "blocked" || runtimeDisplayStatus === "waiting-for-host" || isFollowUpSubmitting || @@ -2127,7 +2060,6 @@ export function ThreadDetailPromptArea({ ), [ canUseGitUi, - durableSubmission.connected, childPendingInteractionBanners, contextBannerMergeBase, environmentHostId, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index d92ebb414e6..b0604c451b7 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -877,7 +877,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { } = useThreadTimelineController({ threadId, }); - const sendMessage = useSendThreadMessage(threadId); + const sendMessage = useSendThreadMessage(); const editMessage = useEditThreadMessage(); const createQueuedMessage = useCreateThreadQueuedMessage(); const requestEnvironmentAction = useRequestEnvironmentAction(); diff --git a/apps/cli/src/__tests__/command-output/thread-tell.test.ts b/apps/cli/src/__tests__/command-output/thread-tell.test.ts index 4b372431f43..b94badaed04 100644 --- a/apps/cli/src/__tests__/command-output/thread-tell.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-tell.test.ts @@ -37,49 +37,6 @@ describe("bb thread tell command output", () => { }); }); - it("forwards submission IDs and uses follow-up mode for ordinary durable messages", async () => { - const post = vi.fn(async () => ({ ok: true, delivery: "sent" })); - const create = vi.fn(async () => ({ id: "qmsg_submission" })); - stubServerApi({ - "v1.threads.:id.send.$post": post, - "v1.threads.:id.queued-messages.$post": create, - }); - await runCommand( - [ - "thread", - "tell", - "thread-submission", - "hello", - "--submission-id", - "send_nanoid", - ], - register, - ); - await runCommand( - [ - "thread", - "queue", - "create", - "thread-submission", - "hello", - "--submission-id", - "queue_nanoid", - ], - register, - ); - expect(post).toHaveBeenCalledWith({ - param: { id: "thread-submission" }, - json: expect.objectContaining({ - clientSubmissionId: "send_nanoid", - mode: "queue-if-active", - }), - }); - expect(create).toHaveBeenCalledWith({ - param: { id: "thread-submission" }, - json: expect.objectContaining({ clientSubmissionId: "queue_nanoid" }), - }); - }); - it("bb thread tell --message-file sends shell-active text untouched", async () => { const dir = await mkdtemp(join(tmpdir(), "bb-tell-file-")); const path = join(dir, "message.md"); diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index b4071a3ee36..f7a4aff90b0 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -69,7 +69,6 @@ interface ThreadDeleteCommandOptions { } interface ThreadTellCommandOptions { - submissionId?: string; json?: boolean; messageFile?: string; model?: string; @@ -107,7 +106,6 @@ interface ThreadEditMessageCommandOptions { type ThreadTellDeliveryMode = "auto" | "queue" | "steer"; interface PostThreadMessageArgs { - clientSubmissionId?: string; getUrl: () => string; threadId: string; message: string; @@ -442,10 +440,6 @@ export function registerActionsCommands( .command("tell [message]") .aliases(["message", "send"]) .description("Send a follow-up message to a thread") - .option( - "--submission-id ", - "Stable ID for retrying an ordinary follow-up without duplicate admission", - ) .option( "--message-file ", `Read the message from a file instead of [message]; ${TEXT_FILE_HELP_SUFFIX}`, @@ -490,14 +484,10 @@ export function registerActionsCommands( inlineLabel: "", }); const response = await postThreadMessage({ - clientSubmissionId: opts.submissionId, getUrl, threadId: id, message, - mode: - opts.submissionId !== undefined && opts.mode === undefined - ? "queue" - : resolveThreadMessageMode(opts.mode), + mode: resolveThreadMessageMode(opts.mode), model: opts.model, permissionMode: parsePermissionMode(opts.permissionMode), reasoningLevel: parseReasoningLevel(opts.reasoningLevel), @@ -614,9 +604,6 @@ async function postThreadMessage( sdk, }); const response = await sdk.threads.send({ - ...(args.clientSubmissionId === undefined - ? {} - : { clientSubmissionId: args.clientSubmissionId }), threadId: args.threadId, input, mode: diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index ed9508a5967..f5e53141b51 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -49,7 +49,6 @@ interface QueueListOptions extends JsonOptions { } interface QueueCreateOptions extends JsonOptions { - submissionId?: string; messageFile?: string; model?: string; } @@ -343,10 +342,6 @@ export function registerOrganizationCommands( queue .command("create [message]") .description("Create a queued text message") - .option( - "--submission-id ", - "Stable ID for retrying the same queued follow-up without duplicates", - ) .option("--model ", "Model override for the queued message") .option( "--message-file ", @@ -369,9 +364,6 @@ export function registerOrganizationCommands( const result = await createCliBbSdk( getUrl(), ).threads.queuedMessages.create({ - ...(opts.submissionId === undefined - ? {} - : { clientSubmissionId: opts.submissionId }), threadId, input: [{ type: "text", text: message, mentions: [] }], ...(opts.model ? { model: opts.model } : {}), diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 31809ce63d7..9953591c12f 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -207,10 +207,7 @@ export function registerSystemRoutes( ), customThemes: listCustomThemeNames(themeRoot), pluginThemes: pluginService.listThemes(), - featureFlags: { - ...deps.config.featureFlags, - durableMessageDelivery: true, - }, + featureFlags: deps.config.featureFlags, hostDaemonPort: deps.config.hostDaemonPort, localHelperPorts, serverUrl, diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index 20ab3f59d49..6b62d8380b1 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -1,7 +1,3 @@ -import { - acceptThreadSubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { requestQueuedMachineReadiness } from "./queued-message-dispatch.js"; import { cancelPreparingMachinePause, @@ -219,7 +215,6 @@ export type DispatchAttemptSource = }; export interface DispatchAttemptArgs { - submission?: ThreadSubmissionReceipt; thread: Thread; payload: SendMessageRequest & { inputGroups?: PromptInput[][] }; source: DispatchAttemptSource; @@ -354,7 +349,6 @@ async function runDispatchAttempt( ); const resolvedPayload = resolveExecutionIntoPayload(payload, execution); const queuedMessage: QueuedDispatchMessage = { - submission: args.submission, input: payload.input, execution, senderThreadId, @@ -498,7 +492,6 @@ async function runDispatchAttempt( if (firstDispatch) { admitted.value = await admitPendingThread(deps, { - submission: args.submission, claimed, payload: resolvedPayload, respectManualStopPause, @@ -595,7 +588,6 @@ async function runDispatchAttempt( const environment = await requireThreadCommandEnvironment(deps, { thread }); try { await sendThreadMessage(deps, { - submission: args.submission, environment, payload: resolvedPayload, thread, @@ -677,7 +669,6 @@ function consumeClaimedRows( } interface AdmitPendingThreadArgs { - submission?: ThreadSubmissionReceipt; claimed: ClaimedQueuedThreadMessageRow[] | null; payload: SendMessageRequest & { inputGroups?: PromptInput[][] }; respectManualStopPause: boolean; @@ -741,53 +732,41 @@ async function admitPendingThread( let startingThread: Thread; try { startingThread = deps.db.transaction( - (tx) => - acceptThreadSubmission( - tx, - args.submission, - () => { - // The row is consumed and the thread flipped in ONE transaction: a - // flip that loses to a concurrent attempt rolls the consumption back, - // so the row stays claimed for the caller to hand back rather than - // being deleted under a message that never dispatched. - if (args.claimed !== null && args.claimed.length > 0) { - consumeClaimedRows( - args.claimed, - args.thread.id, - args.respectManualStopPause, - )({ tx }); - } - const prepared = applyLoggedThreadLifecycleEventInTransaction( - { db: tx, logger: deps.logger }, - { threadId: args.thread.id, event: { type: "run.preparing" } }, - ); - if (!prepared.applied) { - throw new PendingThreadAdmissionLost(); - } - const starting = getThread(tx, args.thread.id); - if (starting === null) throw new PendingThreadAdmissionLost(); - const provision = requestThreadProvision(deps, { - thread: starting, - environmentIntent: startContext.environmentIntent, - execution, - fork: startContext.fork, - input: args.payload.input, - ...(startContext.providerInput === undefined - ? {} - : { providerInput: startContext.providerInput }), - startedOnBehalfOf: startContext.startedOnBehalfOf, - titleProvided: startContext.titleProvided, - }); - return { - starting, - turnRequestId: provision.request.clientRequestId, - }; - }, - (accepted) => ({ - delivery: "sent", - turnRequestId: accepted.turnRequestId, - }), - ).starting, + (tx) => { + // The row is consumed and the thread flipped in ONE transaction: a + // flip that loses to a concurrent attempt rolls the consumption back, + // so the row stays claimed for the caller to hand back rather than + // being deleted under a message that never dispatched. + if (args.claimed !== null && args.claimed.length > 0) { + consumeClaimedRows( + args.claimed, + args.thread.id, + args.respectManualStopPause, + )({ tx }); + } + const prepared = applyLoggedThreadLifecycleEventInTransaction( + { db: tx, logger: deps.logger }, + { threadId: args.thread.id, event: { type: "run.preparing" } }, + ); + if (!prepared.applied) { + throw new PendingThreadAdmissionLost(); + } + const starting = getThread(tx, args.thread.id); + if (starting === null) throw new PendingThreadAdmissionLost(); + requestThreadProvision(deps, { + thread: starting, + environmentIntent: startContext.environmentIntent, + execution, + fork: startContext.fork, + input: args.payload.input, + ...(startContext.providerInput === undefined + ? {} + : { providerInput: startContext.providerInput }), + startedOnBehalfOf: startContext.startedOnBehalfOf, + titleProvided: startContext.titleProvided, + }); + return starting; + }, { behavior: "immediate" }, ); } catch (error) { diff --git a/apps/server/src/services/threads/queue-waits.ts b/apps/server/src/services/threads/queue-waits.ts index 4ea0859599e..03bb9c3c20b 100644 --- a/apps/server/src/services/threads/queue-waits.ts +++ b/apps/server/src/services/threads/queue-waits.ts @@ -1,7 +1,3 @@ -import { - acceptThreadSubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { clearQueuedThreadMessageWaitingOn, createQueuedThreadMessageInTransaction, @@ -65,7 +61,6 @@ export interface SettleQueueRowArgs { /** The message a queued row will carry, as the queueing site supplies it. */ export interface QueuedDispatchMessage { - submission?: ThreadSubmissionReceipt; input: PromptInput[]; execution: ResolvedThreadExecutionOptions; senderThreadId: string | null; @@ -124,32 +119,22 @@ export function recordQueuedMessageWait( if (leadClaim === undefined) { row = deps.db.transaction( (tx) => - acceptThreadSubmission( - tx, - args.message.submission, - () => - createQueuedThreadMessageInTransaction(tx, { - clientSubmissionId: args.message.submission?.id, - threadId: args.thread.id, - content: args.message.input, - senderThreadId: args.message.senderThreadId, - origin: args.message.origin, - originPluginId: args.message.originPluginId, - requestedBy: args.message.requestedBy, - model: args.message.execution.model, - reasoningLevel: args.message.execution.reasoningLevel, - permissionMode: args.message.execution.permissionMode, - serviceTier: args.message.execution.serviceTier, - waitingOn: args.waitingOn, - sendAt: args.sendAt, - payload: args.message.payload, - systemNotice: args.message.systemNotice, - }), - (row) => ({ - delivery: "queued", - queuedMessage: toThreadQueuedMessage(row), - }), - ), + createQueuedThreadMessageInTransaction(tx, { + threadId: args.thread.id, + content: args.message.input, + senderThreadId: args.message.senderThreadId, + origin: args.message.origin, + originPluginId: args.message.originPluginId, + requestedBy: args.message.requestedBy, + model: args.message.execution.model, + reasoningLevel: args.message.execution.reasoningLevel, + permissionMode: args.message.execution.permissionMode, + serviceTier: args.message.execution.serviceTier, + waitingOn: args.waitingOn, + sendAt: args.sendAt, + payload: args.message.payload, + systemNotice: args.message.systemNotice, + }), { behavior: "immediate" }, ); } else { diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index f6f7ee28e37..e2fa2fb4689 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -1,9 +1,3 @@ -import { - acceptThreadSubmission, - withThreadSubmissionReceipt, - requireOrdinarySubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { claimNextQueuedThreadMessageGroup, claimQueuedThreadMessageGroup, @@ -152,10 +146,7 @@ export function createAutomaticQueuedMessageGroupEligibility( case "thread-busy": case "stopping": return ( - args.thread.status === "idle" || - args.thread.status === "pending" || - (args.thread.status === "error" && - member.clientSubmissionId !== null) + args.thread.status === "idle" || args.thread.status === "pending" ); case "turn-starting": return ( @@ -224,36 +215,8 @@ function admitQueuedMessage( export async function createQueuedMessageForThread( deps: LoggedPendingInteractionWorkSessionDeps, args: CreateQueuedMessageForThreadArgs, -): Promise { - const response = await withThreadSubmissionReceipt( - deps.db, - { - threadId: args.thread.id, - operation: "queue", - payload: args.payload, - }, - async (submission) => ({ - ok: true, - delivery: "queued", - queuedMessage: await createQueuedMessageForThreadOnce( - deps, - args, - submission, - ), - }), - ); - if (response.delivery !== "queued") - throw new Error("Invalid queued submission receipt"); - return response.queuedMessage; -} - -async function createQueuedMessageForThreadOnce( - deps: LoggedPendingInteractionWorkSessionDeps, - args: CreateQueuedMessageForThreadArgs, - submission: ThreadSubmissionReceipt | undefined, ): Promise { const { payload, thread } = args; - if (submission !== undefined) requireOrdinarySubmission(payload.input); ensureThreadQueueIsWritable(thread); await validatePromptAttachmentReferences({ db: deps.db, @@ -270,52 +233,39 @@ async function createQueuedMessageForThreadOnce( }); const { currentThread, hasProviderSession, queuedMessage } = deps.db.transaction( - (tx) => - acceptThreadSubmission( - tx, - submission, - () => { - const currentThread = getThread(tx, thread.id); - if (!currentThread) { - throw new ApiError(404, "thread_not_found", "Thread not found"); - } - const { hasProviderSession } = admitQueuedMessage( - tx, - currentThread, - ); - const queuedMessage = createQueuedThreadMessageInTransaction(tx, { - clientSubmissionId: submission?.id, - threadId: thread.id, - content: payload.input, - senderThreadId, - model: execution.model, - reasoningLevel: execution.reasoningLevel, - permissionMode: execution.permissionMode, - serviceTier: execution.serviceTier, - // An explicit "queue this" is a message waiting for the running turn - // to end, which is exactly `thread-busy`. Naming it rather than - // leaving the wait null keeps every row on one vocabulary, and the - // idle drain treats the two identically anyway. - // - // Queued while the thread is stopping, it is instead a message the - // user composed AFTER asking for the stop, so it carries `stopping` - // and runs when the stop lands rather than joining the rows the - // manual-stop pause holds back. - waitingOn: - currentThread.status === "stopping" - ? { kind: "stopping" } - : { kind: "thread-busy" }, - sendAt: null, - payload: { kind: "inline" }, - systemNotice: null, - }); - return { currentThread, hasProviderSession, queuedMessage }; - }, - (accepted) => ({ - delivery: "queued", - queuedMessage: toThreadQueuedMessage(accepted.queuedMessage), - }), - ), + (tx) => { + const currentThread = getThread(tx, thread.id); + if (!currentThread) { + throw new ApiError(404, "thread_not_found", "Thread not found"); + } + const { hasProviderSession } = admitQueuedMessage(tx, currentThread); + const queuedMessage = createQueuedThreadMessageInTransaction(tx, { + threadId: thread.id, + content: payload.input, + senderThreadId, + model: execution.model, + reasoningLevel: execution.reasoningLevel, + permissionMode: execution.permissionMode, + serviceTier: execution.serviceTier, + // An explicit "queue this" is a message waiting for the running turn + // to end, which is exactly `thread-busy`. Naming it rather than + // leaving the wait null keeps every row on one vocabulary, and the + // idle drain treats the two identically anyway. + // + // Queued while the thread is stopping, it is instead a message the + // user composed AFTER asking for the stop, so it carries `stopping` + // and runs when the stop lands rather than joining the rows the + // manual-stop pause holds back. + waitingOn: + currentThread.status === "stopping" + ? { kind: "stopping" } + : { kind: "thread-busy" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + return { currentThread, hasProviderSession, queuedMessage }; + }, { behavior: "immediate" }, ); deps.hub.notifyThread(thread.id, ["queue-changed"]); @@ -326,11 +276,7 @@ async function createQueuedMessageForThreadOnce( providerId: thread.providerId, }); } - if ( - (currentThread.status === "idle" && hasProviderSession) || - (submission !== undefined && - ["idle", "error", "pending"].includes(currentThread.status)) - ) { + if (currentThread.status === "idle" && hasProviderSession) { requestQueuedMessageDispatch(deps, { kind: "thread-ready", threadId: thread.id, @@ -752,11 +698,6 @@ async function sendClaimedQueuedMessageForThread( lead.senderThreadId, ), ...(inputGroups.length > 1 ? { inputGroups } : {}), - ...(args.mode === "auto" && - !args.sendNow && - queuedMessages.some((entry) => entry.clientSubmissionId !== undefined) - ? { mode: "queue-if-active" as const } - : {}), }, source: { kind: "drain", diff --git a/apps/server/src/services/threads/thread-provisioning.ts b/apps/server/src/services/threads/thread-provisioning.ts index fea8b8d3ddd..fe2157ed752 100644 --- a/apps/server/src/services/threads/thread-provisioning.ts +++ b/apps/server/src/services/threads/thread-provisioning.ts @@ -1,7 +1,3 @@ -import { - acceptThreadSubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { getNonDestroyedHostByLaunchKey } from "@bb/db"; import { sweepProviderMachine } from "../machines/provider-orchestration.js"; import { cancelProviderEnvironmentCreation } from "../environments/environment-engine.js"; @@ -67,7 +63,6 @@ interface RequestThreadProvisionArgs { } interface RequestThreadTargetReprovisionArgs { - submission?: ThreadSubmissionReceipt; beforeRequestAppendInTransaction?: (args: { tx: DbTransaction }) => void; environment: EnvironmentRow; execution: ResolvedThreadExecutionOptions; @@ -325,48 +320,42 @@ function appendReprovisionTurnRequest( ) { const requestId = createClientTurnRequestId(); const request = deps.db.transaction( - (tx) => - acceptThreadSubmission( - tx, - args.submission, - () => { - args.beforeRequestAppendInTransaction?.({ tx }); - const request = - appendPreparedClientTurnRequestedEventWithNotificationInTransaction( - tx, - { - threadId: args.thread.id, - environmentId: args.environment.id, - type: "client/turn/requested", - input: args.input, - ...(args.inputGroups !== undefined - ? { inputGroups: args.inputGroups } - : {}), - execution: args.execution, - initiator: args.initiator, - senderThreadId: args.senderThreadId, - systemMessageKind: args.systemMessageKind, - systemMessageSubject: args.systemMessageSubject, - requestMethod: "turn/start", - source: "tell", - target: { kind: "new-turn" }, - requestId, - }, - ); - recordAcceptedPromptHistoryEntry( - { db: tx }, - { - thread: args.thread, - input: args.input, - initiator: args.initiator, - target: { kind: "new-turn" }, - requestSequence: request.sequence, - }, - ); - return request; + (tx) => { + args.beforeRequestAppendInTransaction?.({ tx }); + const request = + appendPreparedClientTurnRequestedEventWithNotificationInTransaction( + tx, + { + threadId: args.thread.id, + environmentId: args.environment.id, + type: "client/turn/requested", + input: args.input, + ...(args.inputGroups !== undefined + ? { inputGroups: args.inputGroups } + : {}), + execution: args.execution, + initiator: args.initiator, + senderThreadId: args.senderThreadId, + systemMessageKind: args.systemMessageKind, + systemMessageSubject: args.systemMessageSubject, + requestMethod: "turn/start", + source: "tell", + target: { kind: "new-turn" }, + requestId, + }, + ); + recordAcceptedPromptHistoryEntry( + { db: tx }, + { + thread: args.thread, + input: args.input, + initiator: args.initiator, + target: { kind: "new-turn" }, + requestSequence: request.sequence, }, - (accepted) => ({ delivery: "sent", turnRequestId: accepted.requestId }), - ), + ); + return request; + }, { behavior: "immediate" }, ); deps.hub.notifyThread( diff --git a/apps/server/src/services/threads/thread-queued-messages.ts b/apps/server/src/services/threads/thread-queued-messages.ts index a3717b2d579..5b58086aea9 100644 --- a/apps/server/src/services/threads/thread-queued-messages.ts +++ b/apps/server/src/services/threads/thread-queued-messages.ts @@ -19,7 +19,6 @@ import { ApiError } from "../../errors.js"; import { resolveDispatchAuthor } from "./dispatch-author.js"; interface StoredQueuedThreadMessageRow { - clientSubmissionId?: string | null; origin: ThreadCreateOrigin | null; originPluginId: string | null; claimedAt: number | null; @@ -170,9 +169,6 @@ export function toThreadQueuedMessage( }); return threadQueuedMessageSchema.parse({ id: row.id, - ...(row.clientSubmissionId == null - ? {} - : { clientSubmissionId: row.clientSubmissionId }), origin: row.origin, originPluginId: row.originPluginId, initiator: author.initiator, diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index d9e4009e154..132257c3c4f 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -1,8 +1,3 @@ -import { - withThreadSubmissionReceipt, - requireOrdinarySubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { isStandaloneBuiltinClearCommand, type Thread } from "@bb/domain"; import type { SendMessageRequest, @@ -22,24 +17,6 @@ export async function acceptThreadSendRequest( deps: LoggedPendingInteractionWorkSessionDeps, args: AcceptThreadSendRequestArgs, ): Promise { - return withThreadSubmissionReceipt( - deps.db, - { - threadId: args.thread.id, - operation: "send", - payload: args.payload, - }, - (submission) => acceptThreadSendRequestOnce(deps, args, submission), - ); -} - -async function acceptThreadSendRequestOnce( - deps: LoggedPendingInteractionWorkSessionDeps, - args: AcceptThreadSendRequestArgs, - submission: ThreadSubmissionReceipt | undefined, -): Promise { - if (submission !== undefined) - requireOrdinarySubmission(args.payload.input, args.payload.mode); if (isStandaloneBuiltinClearCommand(args.payload.input)) { const environment = await requireThreadCommandEnvironment(deps, { thread: args.thread, @@ -55,11 +32,7 @@ async function acceptThreadSendRequestOnce( const outcome = await attemptDispatch(deps, { thread: args.thread, - payload: - submission === undefined - ? args.payload - : { ...args.payload, mode: "queue-if-active" }, - submission, + payload: args.payload, source: { kind: "inline" }, queuePayload: { kind: "inline" }, pluginSubmission: args.payload.pluginSubmission ?? null, diff --git a/apps/server/src/services/threads/thread-send.ts b/apps/server/src/services/threads/thread-send.ts index fd1ef934b6b..53b0170ce43 100644 --- a/apps/server/src/services/threads/thread-send.ts +++ b/apps/server/src/services/threads/thread-send.ts @@ -1,7 +1,3 @@ -import { - acceptThreadSubmission, - type ThreadSubmissionReceipt, -} from "./thread-submission-receipts.js"; import { getEnvironment, getThread, @@ -91,7 +87,6 @@ type SendThreadMessagePayload = SendMessageRequest & { }; interface SendThreadMessageArgs { - submission?: ThreadSubmissionReceipt; beforeAppendInTransaction?: SendThreadMessageTransactionPreflight; /** * Present only when this send re-submits a failed turn. Marks the turn event @@ -148,7 +143,6 @@ interface SendThreadMessageQueueRequest { } interface AppendAndQueueSendThreadMessageArgs { - submission?: ThreadSubmissionReceipt; /** Retry provenance; absent for an original dispatch. */ retryOf?: TurnRequestRetryMarker; beforeAppendInTransaction?: SendThreadMessageTransactionPreflight; @@ -400,7 +394,6 @@ export function captureUserMessageSentTelemetry( } function appendAndQueueSendThreadMessageInTransaction({ - submission, retryOf, beforeAppendInTransaction, db, @@ -417,50 +410,44 @@ function appendAndQueueSendThreadMessageInTransaction({ }: AppendAndQueueSendThreadMessageArgs): AppendAndQueueSendThreadMessageResult { let activeThread: Thread | null = null; const request = db.transaction( - (tx) => - acceptThreadSubmission( - tx, - submission, - () => { - beforeAppendInTransaction?.({ tx }); - const appended = - appendPreparedClientTurnRequestedEventWithNotificationInTransaction( - tx, - { - threadId: thread.id, - environmentId, - type: "client/turn/requested", - ...(retryOf !== undefined ? { retryOf } : {}), - input, - ...(inputGroups !== undefined ? { inputGroups } : {}), - execution, - initiator, - senderThreadId, - requestMethod: "turn/start", - source: "tell", - target, - requestId, - }, - ); - recordAcceptedPromptHistoryEntry( - { db: tx }, - { - thread, - input, - initiator, - target, - requestSequence: appended.sequence, - }, - ); - const queueResult = queueInTransaction({ - requestEventSequence: appended.sequence, - tx, - }); - activeThread = queueResult.activeThread; - return appended; + (tx) => { + beforeAppendInTransaction?.({ tx }); + const appended = + appendPreparedClientTurnRequestedEventWithNotificationInTransaction( + tx, + { + threadId: thread.id, + environmentId, + type: "client/turn/requested", + ...(retryOf !== undefined ? { retryOf } : {}), + input, + ...(inputGroups !== undefined ? { inputGroups } : {}), + execution, + initiator, + senderThreadId, + requestMethod: "turn/start", + source: "tell", + target, + requestId, + }, + ); + recordAcceptedPromptHistoryEntry( + { db: tx }, + { + thread, + input, + initiator, + target, + requestSequence: appended.sequence, }, - (request) => ({ delivery: "sent", turnRequestId: request.requestId }), - ), + ); + const queueResult = queueInTransaction({ + requestEventSequence: appended.sequence, + tx, + }); + activeThread = queueResult.activeThread; + return appended; + }, { behavior: "immediate" }, ); return { @@ -599,7 +586,6 @@ async function sendThreadMessageWithoutContextClear( if ( await dispatchTurnDuringReprovision({ - submission: args.submission, beforeRequestAppendInTransaction: beforeAppendInTransaction, deps, environment, @@ -676,7 +662,6 @@ async function sendThreadMessageWithoutContextClear( } : await prepareReadyThreadTurnCommand(deps, commandArgs); const queuedRequest = appendAndQueueSendThreadMessageInTransaction({ - submission: args.submission, ...(args.retryOf !== undefined ? { retryOf: args.retryOf } : {}), beforeAppendInTransaction: ({ tx }) => { beforeAppendInTransaction({ tx }); @@ -773,7 +758,6 @@ async function sendThreadMessageWithoutContextClear( requestId, }); const queuedRequest = appendAndQueueSendThreadMessageInTransaction({ - submission: args.submission, ...(args.retryOf !== undefined ? { retryOf: args.retryOf } : {}), beforeAppendInTransaction, db: deps.db, diff --git a/apps/server/src/services/threads/thread-submission-receipts.ts b/apps/server/src/services/threads/thread-submission-receipts.ts deleted file mode 100644 index f097ede946a..00000000000 --- a/apps/server/src/services/threads/thread-submission-receipts.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { createHash } from "node:crypto"; -import { - threadSubmissionReceipts, - type DbConnection, - type DbTransaction, -} from "@bb/db"; -import { - clientTurnRequestIdSchema, - jsonValueSchema, - threadQueuedMessageSchema, - type JsonValue, - type PromptInput, -} from "@bb/domain"; -import type { - CreateQueuedMessageRequest, - SendMessageRequest, - SendMessageResponse, -} from "@bb/server-contract"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { ApiError } from "../../errors.js"; - -const acceptedSubmissionSchema = z.discriminatedUnion("delivery", [ - z.object({ - delivery: z.literal("sent"), - turnRequestId: clientTurnRequestIdSchema, - }), - z.object({ - delivery: z.literal("queued"), - queuedMessage: threadQueuedMessageSchema.omit({ - content: true, - replayed: true, - }), - }), -]); - -type AcceptedSubmission = z.infer; - -export interface ThreadSubmissionReceipt { - id: string; - accept( - tx: DbTransaction, - admit: () => T, - result: (value: T) => AcceptedSubmission, - ): T; -} - -interface PendingSubmission { - fingerprint: string; - promise: Promise; -} - -const pendingSubmissions = new WeakMap< - DbConnection, - Map ->(); - -function canonicalJson(value: JsonValue): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (value !== null && typeof value === "object") { - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key]!)}`) - .join(",")}}`; - } - return JSON.stringify(value); -} - -function submissionConflict(): ApiError { - return new ApiError( - 409, - "client_submission_conflict", - "This submission ID was already used for a different message", - ); -} - -export function requireOrdinarySubmission( - input: PromptInput[], - mode?: SendMessageRequest["mode"], -): void { - if ( - (mode !== undefined && mode !== "start" && mode !== "queue-if-active") || - input.some( - (block) => - block.type === "text" && - block.mentions.some( - (mention) => - mention.resource.kind === "command" && - mention.resource.source === "command", - ), - ) - ) { - throw new ApiError( - 400, - "client_submission_unsupported", - "Durable delivery is only available for ordinary messages and queued follow-ups", - ); - } -} - -export function acceptThreadSubmission( - tx: DbTransaction, - receipt: ThreadSubmissionReceipt | undefined, - admit: () => T, - result: (value: T) => AcceptedSubmission, -): T { - return receipt === undefined ? admit() : receipt.accept(tx, admit, result); -} - -function markReplayed(response: SendMessageResponse): SendMessageResponse { - return response.delivery === "sent" - ? { ...response, replayed: true } - : { - ...response, - replayed: true, - queuedMessage: { ...response.queuedMessage, replayed: true }, - }; -} - -export async function withThreadSubmissionReceipt( - db: DbConnection, - args: { - threadId: string; - operation: "send" | "queue"; - payload: SendMessageRequest | CreateQueuedMessageRequest; - }, - submit: ( - receipt: ThreadSubmissionReceipt | undefined, - ) => Promise, -): Promise { - const clientSubmissionId = args.payload.clientSubmissionId; - if (clientSubmissionId === undefined) return submit(undefined); - const requestJson = JSON.stringify(args.payload); - const fingerprint = createHash("sha256") - .update(canonicalJson(jsonValueSchema.parse(JSON.parse(requestJson)))) - .digest("hex"); - const where = and( - eq(threadSubmissionReceipts.threadId, args.threadId), - eq(threadSubmissionReceipts.clientSubmissionId, clientSubmissionId), - ); - const readAccepted = (): SendMessageResponse | null => { - const row = db.select().from(threadSubmissionReceipts).where(where).get(); - if (row === undefined) return null; - if (row.fingerprint !== fingerprint || row.operation !== args.operation) - throw submissionConflict(); - if (row.result === null) - throw new ApiError( - 500, - "internal_error", - "Submission receipt was not completed", - ); - const result = acceptedSubmissionSchema.parse(JSON.parse(row.result)); - if (result.delivery === "sent") - return { - ok: true, - delivery: "sent", - turnRequestId: result.turnRequestId, - clientSubmissionId, - }; - return { - ok: true, - delivery: "queued", - clientSubmissionId, - queuedMessage: { - ...result.queuedMessage, - content: args.payload.input, - clientSubmissionId, - }, - }; - }; - const accepted = readAccepted(); - if (accepted !== null) return markReplayed(accepted); - const pending = - pendingSubmissions.get(db) ?? new Map(); - pendingSubmissions.set(db, pending); - const key = JSON.stringify([args.threadId, clientSubmissionId]); - const operationFingerprint = `${args.operation}:${fingerprint}`; - const existing = pending.get(key); - if (existing !== undefined) { - if (existing.fingerprint !== operationFingerprint) - throw submissionConflict(); - return existing.promise.then(markReplayed); - } - const receipt: ThreadSubmissionReceipt = { - id: clientSubmissionId, - accept(tx, admit, result) { - const inserted = tx - .insert(threadSubmissionReceipts) - .values({ - threadId: args.threadId, - clientSubmissionId, - operation: args.operation, - fingerprint, - result: null, - createdAt: Date.now(), - }) - .onConflictDoNothing() - .returning() - .get(); - if (inserted === undefined) - throw new ApiError( - 409, - "client_submission_in_progress", - "Submission is already being accepted", - ); - const value = admit(); - const acceptance = acceptedSubmissionSchema.parse(result(value)); - tx.update(threadSubmissionReceipts) - .set({ result: JSON.stringify(acceptance) }) - .where(where) - .run(); - return value; - }, - }; - const promise = Promise.resolve().then(async () => { - try { - await submit(receipt); - const acceptance = readAccepted(); - if (acceptance === null) - throw new ApiError( - 500, - "internal_error", - "Message was not durably accepted", - ); - return acceptance; - } catch (error) { - const acceptance = readAccepted(); - if (acceptance !== null) return markReplayed(acceptance); - if (error instanceof ApiError) { - const details = error.body.details; - if ( - details === undefined || - (details !== null && - typeof details === "object" && - !Array.isArray(details)) - ) { - error.body.details = { - ...details, - submission: { clientSubmissionId, acceptance: "rejected" }, - }; - } - } - throw error; - } finally { - pending.delete(key); - } - }); - pending.set(key, { fingerprint: operationFingerprint, promise }); - return promise; -} diff --git a/apps/server/src/services/threads/thread-turn-dispatch.ts b/apps/server/src/services/threads/thread-turn-dispatch.ts index 1c084216636..6fd10dc7d91 100644 --- a/apps/server/src/services/threads/thread-turn-dispatch.ts +++ b/apps/server/src/services/threads/thread-turn-dispatch.ts @@ -1,4 +1,3 @@ -import type { ThreadSubmissionReceipt } from "./thread-submission-receipts.js"; import type { PromptInput, ResolvedThreadExecutionOptions, @@ -27,7 +26,6 @@ export interface ReadyThreadEnvironment extends EnvironmentRow { } interface DispatchTurnDuringReprovisionArgs { - submission?: ThreadSubmissionReceipt; beforeRequestAppendInTransaction?: (args: { tx: DbTransaction }) => void; deps: LoggedPendingInteractionWorkSessionDeps; environment: EnvironmentRow; @@ -71,7 +69,6 @@ export async function dispatchTurnDuringReprovision( args.environment.status !== "provisioning" ) { requestThreadTargetReprovision(args.deps, { - submission: args.submission, beforeRequestAppendInTransaction: ({ tx }) => { args.beforeRequestAppendInTransaction?.({ tx }); const prepared = applyLoggedThreadLifecycleEventInTransaction( diff --git a/apps/server/test/system/general-settings.test.ts b/apps/server/test/system/general-settings.test.ts index a41c3e17fbe..2ea11e0eebd 100644 --- a/apps/server/test/system/general-settings.test.ts +++ b/apps/server/test/system/general-settings.test.ts @@ -38,7 +38,6 @@ describe("general settings", () => { showUnhandledProviderEvents: false, }); expect(body.primaryHostId).toBeNull(); - expect(body.featureFlags.durableMessageDelivery).toBe(true); }); }); diff --git a/apps/server/test/threads/dispatch-hooks.test.ts b/apps/server/test/threads/dispatch-hooks.test.ts index 46eae1f3082..9953b894e97 100644 --- a/apps/server/test/threads/dispatch-hooks.test.ts +++ b/apps/server/test/threads/dispatch-hooks.test.ts @@ -319,47 +319,6 @@ describe("message.dispatch hook context", () => { }); describe("pending admission races", () => { - it("records first admission before replaying a saved submission", async () => { - await withTestHarness(async (harness) => { - const registry = emptyRegistry(); - registry["message.dispatch"].push({ - pluginId: "limiter", - handler: () => ({ action: "wait", reason: "at capacity" }) as const, - }); - installHooks(registry); - const { host, project } = seedDispatchFixture( - harness, - "host-submission-admission", - ); - const created = await createHookedThread(harness, { - hostId: host.id, - projectId: project.id, - }); - setPluginHookProvider(undefined); - const thread = getThread(harness.db, created.id); - if (!thread) throw new Error("Expected pending thread"); - const payload = { - clientSubmissionId: "pending_nanoid", - input: textInput("admit once"), - mode: "start" as const, - }; - const first = await acceptThreadSendRequest(harness.deps, { - thread, - payload, - }); - expect(first).toMatchObject({ - delivery: "sent", - clientSubmissionId: payload.clientSubmissionId, - turnRequestId: expect.any(String), - }); - const requests = turnRequests(harness, thread.id); - await expect( - acceptThreadSendRequest(harness.deps, { thread, payload }), - ).resolves.toEqual({ ...first, replayed: true }); - expect(turnRequests(harness, thread.id)).toEqual(requests); - }); - }); - it("re-decides a first message that lost the admission instead of calling it sent", async () => { await withTestHarness(async (harness) => { // Park the thread in `pending` with its first message queued, then take diff --git a/apps/server/test/threads/thread-submission-receipts.test.ts b/apps/server/test/threads/thread-submission-receipts.test.ts deleted file mode 100644 index d9b669b2207..00000000000 --- a/apps/server/test/threads/thread-submission-receipts.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { queueInputForStartingTurn } from "../../src/services/threads/thread-turn-starting.js"; -import { buildExecutionOptions } from "../../src/services/threads/thread-commands.js"; -import { createThreadFromRequest } from "../../src/services/threads/thread-create.js"; -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { ApiError } from "../../src/errors.js"; -import { withThreadSubmissionReceipt } from "../../src/services/threads/thread-submission-receipts.js"; -import { - archiveThread, - createConnection, - deleteQueuedThreadMessage, - getThread, - listEvents, - listQueuedThreadMessages, - threadSubmissionReceipts, -} from "@bb/db"; -import { - createStandaloneBuiltinCompactCommandInput, - defaultFeatureFlags, - featureFlagsSchema, - threadScope, -} from "@bb/domain"; -import { sendMessageResponseSchema } from "@bb/server-contract"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { acceptThreadSendRequest } from "../../src/services/threads/thread-send-request.js"; -import { - createQueuedMessageForThread, - sendQueuedMessageNow, -} from "../../src/services/threads/queued-messages.js"; -import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; -import { listQueuedThreadCommands } from "../helpers/commands.js"; -import { textInput } from "../helpers/prompt-input.js"; -import { - seedEnvironment, - seedHostSession, - seedProjectWithSource, - seedThread, - seedThreadRuntimeState, - seedEvent, - seedTurnStarted, -} from "../helpers/seed.js"; -import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; - -function fixture( - harness: TestAppHarness, - status: "idle" | "active" | "error" = "idle", -) { - const { host } = seedHostSession(harness.deps); - const { project } = seedProjectWithSource(harness.deps, { hostId: host.id }); - const environment = seedEnvironment(harness.deps, { - hostId: host.id, - projectId: project.id, - path: "/tmp/submission-receipts", - status: "ready", - }); - const thread = seedThread(harness.deps, { - projectId: project.id, - environmentId: environment.id, - status, - }); - seedThreadRuntimeState(harness.deps, { - environmentId: environment.id, - providerThreadId: "provider-submission", - threadId: thread.id, - }); - return { thread, environment }; -} - -function requestCount(harness: Pick, threadId: string) { - return listEvents(harness.db, { threadId }).filter( - (event) => event.type === "client/turn/requested", - ).length; -} - -afterEach(() => vi.restoreAllMocks()); - -describe("durable submission acceptance", () => { - const clientSubmissionId = "message_nanoid-123"; - - it("coalesces concurrent sends and replays the original turn after reopen and archive", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness); - const before = requestCount(harness, thread.id); - const payload = { - clientSubmissionId, - input: textInput("send once"), - mode: "start" as const, - }; - const [first, duplicate] = await Promise.all([ - acceptThreadSendRequest(harness.deps, { thread, payload }), - acceptThreadSendRequest(harness.deps, { thread, payload }), - ]); - expect(first).toMatchObject({ - ok: true, - delivery: "sent", - clientSubmissionId, - turnRequestId: expect.any(String), - }); - expect(duplicate).toEqual({ ...first, replayed: true }); - expect(requestCount(harness, thread.id)).toBe(before + 1); - archiveThread(harness.db, harness.hub, thread.id); - const reopened = createConnection(harness.db.$client.serialize()); - try { - const archived = getThread(reopened, thread.id); - if (!archived) throw new Error("Expected archived thread"); - await expect( - acceptThreadSendRequest( - { ...harness.deps, db: reopened }, - { thread: archived, payload }, - ), - ).resolves.toEqual({ ...first, replayed: true }); - expect(requestCount({ db: reopened }, thread.id)).toBe(before + 1); - } finally { - reopened.$client.close(); - } - }); - }); - - it("keeps compatible queue replay metadata after consumption without storing the content twice", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const payload = { - clientSubmissionId, - input: textInput("queue once"), - serviceTier: "default" as const, - }; - const [first, duplicate] = await Promise.all([ - createQueuedMessageForThread(harness.deps, { thread, payload }), - createQueuedMessageForThread(harness.deps, { thread, payload }), - ]); - expect(first.clientSubmissionId).toBe(clientSubmissionId); - expect(duplicate).toEqual({ ...first, replayed: true }); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - const receipt = harness.db.select().from(threadSubmissionReceipts).get(); - expect(receipt?.result).not.toContain("queue once"); - deleteQueuedThreadMessage(harness.db, harness.hub, first.id); - const reopened = createConnection(harness.db.$client.serialize()); - try { - const replay = await createQueuedMessageForThread( - { ...harness.deps, db: reopened }, - { thread, payload }, - ); - expect(replay).toEqual({ ...first, replayed: true }); - expect( - sendMessageResponseSchema.parse({ - ok: true, - delivery: "queued", - clientSubmissionId, - replayed: true, - queuedMessage: replay, - }), - ).toMatchObject({ - queuedMessage: { - content: payload.input, - model: first.model, - createdAt: first.createdAt, - }, - }); - expect(listQueuedThreadMessages(reopened, thread.id)).toHaveLength(0); - } finally { - reopened.$client.close(); - } - }); - }); - - it("uses database uniqueness when concurrent attempts have different connections", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const path = join( - harness.config.dataDir, - "submission-concurrency.sqlite", - ); - writeFileSync(path, harness.db.$client.serialize()); - const firstDb = createConnection(path); - const secondDb = createConnection(path); - const payload = { - clientSubmissionId, - input: textInput("one database admission"), - }; - try { - const results = await Promise.all([ - createQueuedMessageForThread( - { ...harness.deps, db: firstDb }, - { thread, payload }, - ), - createQueuedMessageForThread( - { ...harness.deps, db: secondDb }, - { thread, payload }, - ), - ]); - expect(results[0]?.id).toBe(results[1]?.id); - expect(listQueuedThreadMessages(firstDb, thread.id)).toHaveLength(1); - expect( - firstDb.select().from(threadSubmissionReceipts).all(), - ).toHaveLength(1); - } finally { - firstDb.$client.close(); - secondDb.$client.close(); - } - }); - }); - - it("marks only a settled unaccepted API failure while preserving its details", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness); - const error = new ApiError(400, "invalid_request", "Bad content", { - details: { field: "input" }, - }); - await expect( - withThreadSubmissionReceipt( - harness.db, - { - threadId: thread.id, - operation: "send", - payload: { - clientSubmissionId, - input: textInput("rejected"), - mode: "start", - }, - }, - async () => { - throw error; - }, - ), - ).rejects.toMatchObject({ - status: 400, - body: { - code: "invalid_request", - details: { - field: "input", - submission: { clientSubmissionId, acceptance: "rejected" }, - }, - }, - }); - expect( - harness.db.select().from(threadSubmissionReceipts).all(), - ).toHaveLength(0); - }); - }); - - it("records turn-starting queue acceptance in the same transaction", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const payload = { - clientSubmissionId, - input: textInput("wait for startup"), - mode: "queue-if-active" as const, - }; - const execution = await buildExecutionOptions( - harness.deps, - {}, - { threadId: thread.id }, - ); - const submit = () => - withThreadSubmissionReceipt( - harness.db, - { threadId: thread.id, operation: "send", payload }, - async (submission) => { - const outcome = queueInputForStartingTurn(harness.deps, { - claimed: null, - threadId: thread.id, - input: { - submission, - input: payload.input, - execution, - senderThreadId: null, - origin: null, - originPluginId: null, - requestedBy: null, - payload: { kind: "inline" }, - systemNotice: null, - }, - }); - if (outcome.kind !== "queued") - throw new Error("Expected starting queue"); - return { - ok: true, - delivery: "queued", - queuedMessage: outcome.entry, - }; - }, - ); - const first = await submit(); - expect(first).toMatchObject({ - delivery: "queued", - queuedMessage: { - waitingOn: { kind: "turn-starting" }, - clientSubmissionId, - }, - }); - expect(await submit()).toMatchObject({ replayed: true }); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - }); - }); - - it("rejects changed payloads both during an attempt and after acceptance", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const payload = { clientSubmissionId, input: textInput("immutable") }; - const pending = createQueuedMessageForThread(harness.deps, { - thread, - payload, - }); - const changed = () => - createQueuedMessageForThread(harness.deps, { - thread, - payload: { ...payload, input: textInput("changed") }, - }); - await expect(changed()).rejects.toMatchObject({ - status: 409, - body: { code: "client_submission_conflict" }, - }); - await pending; - await expect(changed()).rejects.toMatchObject({ - status: 409, - body: { code: "client_submission_conflict" }, - }); - await expect( - acceptThreadSendRequest(harness.deps, { - thread, - payload: { ...payload, mode: "start" }, - }), - ).rejects.toMatchObject({ - status: 409, - body: { code: "client_submission_conflict" }, - }); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - }); - }); - - it.each(["send", "queue"] as const)( - "rolls back the %s receipt together with a failed admission", - async (operation) => { - await withTestHarness(async (harness) => { - const { thread } = fixture( - harness, - operation === "queue" ? "active" : "idle", - ); - const before = requestCount(harness, thread.id); - const payload = { - clientSubmissionId, - input: textInput("retry after rollback"), - }; - const submit = () => - operation === "send" - ? acceptThreadSendRequest(harness.deps, { - thread, - payload: { ...payload, mode: "start" }, - }) - : createQueuedMessageForThread(harness.deps, { thread, payload }); - const table = - operation === "send" ? "events" : "queued_thread_messages"; - harness.db.$client.exec( - `CREATE TRIGGER reject_submission BEFORE INSERT ON ${table} BEGIN SELECT RAISE(ABORT, 'admission failed'); END`, - ); - await expect(submit()).rejects.toMatchObject( - operation === "send" - ? { cause: { message: "admission failed" } } - : { message: "admission failed" }, - ); - expect( - harness.db.select().from(threadSubmissionReceipts).all(), - ).toHaveLength(0); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); - expect(requestCount(harness, thread.id)).toBe(before); - harness.db.$client.exec("DROP TRIGGER reject_submission"); - await submit(); - expect( - harness.db.select().from(threadSubmissionReceipts).all(), - ).toHaveLength(1); - }); - }, - ); - - it("returns committed acceptance when notification throws after commit", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const payload = { - clientSubmissionId, - input: textInput("accepted before disconnect"), - }; - vi.spyOn(harness.hub, "notifyThread").mockImplementationOnce(() => { - throw new Error("connection closed"); - }); - const accepted = await createQueuedMessageForThread(harness.deps, { - thread, - payload, - }); - expect(accepted).toMatchObject({ clientSubmissionId, replayed: true }); - await expect( - createQueuedMessageForThread(harness.deps, { thread, payload }), - ).resolves.toEqual(accepted); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - }); - }); - - it("queues an ordinary idle send that reaches an active thread instead of steering", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const before = requestCount(harness, thread.id); - const payload = { - clientSubmissionId, - input: textInput("ordinary follow-up"), - mode: "start" as const, - }; - const first = await acceptThreadSendRequest(harness.deps, { - thread, - payload, - }); - expect(first).toMatchObject({ - delivery: "queued", - clientSubmissionId, - queuedMessage: { - clientSubmissionId, - waitingOn: { kind: "thread-busy" }, - }, - }); - await acceptThreadSendRequest(harness.deps, { thread, payload }); - expect(requestCount(harness, thread.id)).toBe(before); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - }); - }); - - it("waits automatically but lets Send now join the active turn for an accepted submission", async () => { - await withTestHarness(async (harness) => { - const { thread, environment } = fixture(harness, "active"); - seedTurnStarted(harness.deps, { - environmentId: environment.id, - providerThreadId: "provider-submission", - threadId: thread.id, - turnId: "turn-submission", - }); - const before = requestCount(harness, thread.id); - const input = textInput("send this accepted follow-up now"); - const queued = await createQueuedMessageForThread(harness.deps, { - thread, - payload: { clientSubmissionId, input }, - }); - await runQueuedMessageDispatch(harness.deps, { - kind: "thread-ready", - threadId: thread.id, - }); - expect(requestCount(harness, thread.id)).toBe(before); - expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ - { id: queued.id, clientSubmissionId }, - ]); - - await expect( - sendQueuedMessageNow(harness.deps, { - mode: "auto", - queuedMessageId: queued.id, - threadId: thread.id, - }), - ).resolves.toEqual({ delivery: "sent" }); - expect(listQueuedThreadMessages(harness.db, thread.id)).toEqual([]); - expect(requestCount(harness, thread.id)).toBe(before + 1); - expect( - listQueuedThreadCommands(harness, "turn.submit", thread.id), - ).toMatchObject([ - { input, target: { mode: "auto", expectedTurnId: "turn-submission" } }, - ]); - }); - }); - - it.each(["idle", "error"] as const)( - "wakes an accepted queued follow-up on an %s thread", - async (status) => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, status); - const before = requestCount(harness, thread.id); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { - clientSubmissionId, - input: textInput("wake this follow-up"), - }, - }); - await runQueuedMessageDispatch(harness.deps, { - kind: "thread-ready", - threadId: thread.id, - }); - expect(requestCount(harness, thread.id)).toBe(before + 1); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); - }); - }, - ); - - it("wakes a queued submission on a never-started existing thread", async () => { - await withTestHarness(async (harness) => { - const { thread: existing, environment } = fixture(harness); - const created = await createThreadFromRequest(harness.deps, { - projectId: existing.projectId, - providerId: existing.providerId, - origin: "app", - input: textInput("scheduled original"), - sendAt: Date.now() + 60_000, - startedOnBehalfOf: null, - environment: { - type: "host", - hostId: environment.hostId, - workspace: { type: "unmanaged", path: "/tmp/submission-receipts" }, - }, - }); - const thread = getThread(harness.db, created.id); - if (!thread) throw new Error("Expected pending thread"); - expect(thread.status).toBe("pending"); - const queued = await createQueuedMessageForThread(harness.deps, { - thread, - payload: { - clientSubmissionId, - input: textInput("ready before scheduled original"), - }, - }); - await runQueuedMessageDispatch(harness.deps, { - kind: "thread-ready", - threadId: thread.id, - }); - expect(getThread(harness.db, thread.id)?.status).not.toBe("pending"); - expect( - listQueuedThreadMessages(harness.db, thread.id).some( - (row) => row.id === queued.id, - ), - ).toBe(false); - expect(requestCount(harness, thread.id)).toBe(1); - }); - }); - - it("preserves manual queue pause and rejects conversation commands before admission", async () => { - await withTestHarness(async (harness) => { - const { thread, environment } = fixture(harness); - const before = requestCount(harness, thread.id); - seedEvent(harness.deps, { - threadId: thread.id, - environmentId: environment.id, - sequence: 3, - type: "system/thread/interrupted", - scope: threadScope(), - data: { reason: "manual-stop" }, - }); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { clientSubmissionId, input: textInput("wait for resume") }, - }); - await runQueuedMessageDispatch(harness.deps, { - kind: "thread-ready", - threadId: thread.id, - }); - expect(requestCount(harness, thread.id)).toBe(before); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); - await expect( - acceptThreadSendRequest(harness.deps, { - thread, - payload: { - clientSubmissionId: "command", - mode: "start", - input: createStandaloneBuiltinCompactCommandInput(), - }, - }), - ).rejects.toMatchObject({ - status: 400, - body: { code: "client_submission_unsupported" }, - }); - }); - }); - - it("does not infer durable support from older feature flags", () => { - expect( - featureFlagsSchema.parse(defaultFeatureFlags).durableMessageDelivery, - ).toBeUndefined(); - }); - - it("retains legacy calls and keeps identical deliberate submissions distinct", async () => { - await withTestHarness(async (harness) => { - const { thread } = fixture(harness, "active"); - const input = textInput("same text"); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { input }, - }); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { input }, - }); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { input, clientSubmissionId: "first" }, - }); - await createQueuedMessageForThread(harness.deps, { - thread, - payload: { input, clientSubmissionId: "second" }, - }); - expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(4); - expect( - harness.db.select().from(threadSubmissionReceipts).all(), - ).toHaveLength(2); - await expect( - acceptThreadSendRequest(harness.deps, { - thread, - payload: { - input, - clientSubmissionId: "steer", - mode: "steer-if-active", - }, - }), - ).rejects.toMatchObject({ - status: 400, - body: { code: "client_submission_unsupported" }, - }); - }); - }); -}); diff --git a/apps/server/test/threads/thread-turn-dispatch.test.ts b/apps/server/test/threads/thread-turn-dispatch.test.ts index 3a3a793555c..cd874c2d630 100644 --- a/apps/server/test/threads/thread-turn-dispatch.test.ts +++ b/apps/server/test/threads/thread-turn-dispatch.test.ts @@ -1,4 +1,3 @@ -import { withThreadSubmissionReceipt } from "../../src/services/threads/thread-submission-receipts.js"; import { getThread, listEvents } from "@bb/db"; import { afterEach, expect, it, vi } from "vitest"; import { dispatchTurnDuringReprovision } from "../../src/services/threads/thread-turn-dispatch.js"; @@ -75,71 +74,3 @@ it("leaves reprovision unstarted when dispatch admission rejects the request", a expect(notifyThread).not.toHaveBeenCalled(); }); }); - -it("replays the durable request accepted through environment reprovisioning", async () => { - await withTestHarness(async (harness) => { - const { host } = seedHostSession(harness.deps); - const { project } = seedProjectWithSource(harness.deps, { - hostId: host.id, - }); - const environment = seedEnvironment(harness.deps, { - hostId: host.id, - projectId: project.id, - status: "error", - path: "/tmp/submission-reprovision", - environmentProviderId: "personal-workspace", - environmentProviderPluginId: "bb-plugin-environment-personal-workspace", - isGitRepo: false, - }); - const thread = seedThread(harness.deps, { - projectId: project.id, - environmentId: environment.id, - status: "idle", - }); - const payload = { - clientSubmissionId: "reprovision_nanoid", - input: textInput("durable reprovision"), - mode: "start" as const, - }; - const execution = await buildExecutionOptions( - harness.deps, - { - model: "gpt-5", - reasoningLevel: "medium", - serviceTier: "default", - permissionMode: "full", - }, - { threadId: thread.id }, - ); - const submit = () => - withThreadSubmissionReceipt( - harness.db, - { threadId: thread.id, operation: "send", payload }, - async (submission) => { - await dispatchTurnDuringReprovision({ - submission, - deps: harness.deps, - environment, - execution, - initiator: "user", - input: payload.input, - senderThreadId: null, - thread, - }); - return { ok: true, delivery: "sent" }; - }, - ); - const accepted = await submit(); - expect(accepted).toMatchObject({ - delivery: "sent", - clientSubmissionId: payload.clientSubmissionId, - turnRequestId: expect.any(String), - }); - await expect(submit()).resolves.toEqual({ ...accepted, replayed: true }); - expect( - listEvents(harness.db, { threadId: thread.id }).filter( - (event) => event.type === "client/turn/requested", - ), - ).toHaveLength(1); - }); -}); diff --git a/packages/db/drizzle/0127_volatile_meteorite.sql b/packages/db/drizzle/0127_volatile_meteorite.sql deleted file mode 100644 index 6c4d83c4494..00000000000 --- a/packages/db/drizzle/0127_volatile_meteorite.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE `thread_submission_receipts` ( - `thread_id` text NOT NULL, - `client_submission_id` text NOT NULL, - `operation` text NOT NULL, - `fingerprint` text NOT NULL, - `result` text, - `created_at` integer NOT NULL, - PRIMARY KEY(`thread_id`, `client_submission_id`), - FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -ALTER TABLE `queued_thread_messages` ADD `client_submission_id` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0127_snapshot.json b/packages/db/drizzle/meta/0127_snapshot.json deleted file mode 100644 index ad379fd99b2..00000000000 --- a/packages/db/drizzle/meta/0127_snapshot.json +++ /dev/null @@ -1,5082 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "0ea2690f-30b0-4712-8f19-18c2af33abfb", - "prevId": "48979c46-bc2b-411d-a6cc-18dfcb7cd457", - "tables": { - "app_settings": { - "name": "app_settings", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "caffeinate": { - "name": "caffeinate", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "show_keyboard_hints": { - "name": "show_keyboard_hints", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "steer_active_thread_on_enter": { - "name": "steer_active_thread_on_enter", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "show_unhandled_provider_events": { - "name": "show_unhandled_provider_events", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "codex_memory_enabled": { - "name": "codex_memory_enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "claude_code_memory_enabled": { - "name": "claude_code_memory_enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "codex_subagents_disabled": { - "name": "codex_subagents_disabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "claude_code_subagents_disabled": { - "name": "claude_code_subagents_disabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "claude_code_workflows_disabled": { - "name": "claude_code_workflows_disabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "keybinding_overrides": { - "name": "keybinding_overrides", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "onboarding_completed_at": { - "name": "onboarding_completed_at", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "app_settings_values": { - "name": "app_settings_values", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "app_theme": { - "name": "app_theme", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "theme_id": { - "name": "theme_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "favicon_color": { - "name": "favicon_color", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'default'" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "apikey": { - "name": "apikey", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "start": { - "name": "start", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "referenceId": { - "name": "referenceId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refillInterval": { - "name": "refillInterval", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refillAmount": { - "name": "refillAmount", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lastRefillAt": { - "name": "lastRefillAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitEnabled": { - "name": "rateLimitEnabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitTimeWindow": { - "name": "rateLimitTimeWindow", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rateLimitMax": { - "name": "rateLimitMax", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "requestCount": { - "name": "requestCount", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "remaining": { - "name": "remaining", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lastRequest": { - "name": "lastRequest", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expiresAt": { - "name": "expiresAt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permissions": { - "name": "permissions", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "configId": { - "name": "configId", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "apikey_key_unique": { - "name": "apikey_key_unique", - "columns": [ - "key" - ], - "isUnique": true - }, - "apikey_reference_id_idx": { - "name": "apikey_reference_id_idx", - "columns": [ - "referenceId" - ], - "isUnique": false - }, - "apikey_config_id_idx": { - "name": "apikey_config_id_idx", - "columns": [ - "configId" - ], - "isUnique": false - } - }, - "foreignKeys": { - "apikey_referenceId_user_id_fk": { - "name": "apikey_referenceId_user_id_fk", - "tableFrom": "apikey", - "tableTo": "user", - "columnsFrom": [ - "referenceId" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "emailVerified": { - "name": "emailVerified", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "createdAt": { - "name": "createdAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updatedAt": { - "name": "updatedAt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "environment_hook_operations": { - "name": "environment_hook_operations", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "operation_id": { - "name": "operation_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "started_at": { - "name": "started_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "finished_at": { - "name": "finished_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "environment_variables": { - "name": "environment_variables", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ciphertext": { - "name": "ciphertext", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "encryption_version": { - "name": "encryption_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "note": { - "name": "note", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "environment_variables_global_name": { - "name": "environment_variables_global_name", - "columns": [ - "name" - ], - "isUnique": true, - "where": "\"environment_variables\".\"project_id\" IS NULL" - }, - "environment_variables_project_name": { - "name": "environment_variables_project_name", - "columns": [ - "project_id", - "name" - ], - "isUnique": true, - "where": "\"environment_variables\".\"project_id\" IS NOT NULL" - } - }, - "foreignKeys": { - "environment_variables_project_id_projects_id_fk": { - "name": "environment_variables_project_id_projects_id_fk", - "tableFrom": "environment_variables", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "environments": { - "name": "environments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_git_repo": { - "name": "is_git_repo", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_worktree": { - "name": "is_worktree", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_branch": { - "name": "base_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_branch": { - "name": "default_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "merge_base_branch": { - "name": "merge_base_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "environment_provider_id": { - "name": "environment_provider_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "environment_provider_plugin_id": { - "name": "environment_provider_plugin_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_owns_path": { - "name": "provider_owns_path", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "environment_provider_selection": { - "name": "environment_provider_selection", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "environment_provider_instance_key": { - "name": "environment_provider_instance_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "retire_at": { - "name": "retire_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "teardown_attempt": { - "name": "teardown_attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "teardown_status": { - "name": "teardown_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "teardown_message": { - "name": "teardown_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "resource": { - "name": "resource", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owner_thread_id": { - "name": "owner_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "attempt": { - "name": "attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pending_log": { - "name": "pending_log", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "claim_path": { - "name": "claim_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'provisioning'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "environments_project_host_path_idx": { - "name": "environments_project_host_path_idx", - "columns": [ - "project_id", - "host_id", - "path" - ], - "isUnique": true - }, - "environments_host_path_lookup_idx": { - "name": "environments_host_path_lookup_idx", - "columns": [ - "host_id", - "path" - ], - "isUnique": false - }, - "environments_owner_thread_idx": { - "name": "environments_owner_thread_idx", - "columns": [ - "owner_thread_id" - ], - "isUnique": true - }, - "environments_claim_idx": { - "name": "environments_claim_idx", - "columns": [ - "host_id", - "claim_path" - ], - "isUnique": false - }, - "environments_project_idx": { - "name": "environments_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "environments_status_idx": { - "name": "environments_status_idx", - "columns": [ - "status" - ], - "isUnique": false - }, - "environments_provider_instance_idx": { - "name": "environments_provider_instance_idx", - "columns": [ - "environment_provider_id", - "environment_provider_instance_key" - ], - "isUnique": false - } - }, - "foreignKeys": { - "environments_project_id_projects_id_fk": { - "name": "environments_project_id_projects_id_fk", - "tableFrom": "environments", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "environments_host_id_hosts_id_fk": { - "name": "environments_host_id_hosts_id_fk", - "tableFrom": "environments", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope_kind": { - "name": "scope_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "turn_id": { - "name": "turn_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_thread_id": { - "name": "provider_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "item_id": { - "name": "item_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "item_kind": { - "name": "item_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "parent_tool_call_id": { - "name": "parent_tool_call_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'{}'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "events_thread_sequence_idx": { - "name": "events_thread_sequence_idx", - "columns": [ - "thread_id", - "sequence" - ], - "isUnique": true - }, - "events_delegating_item_lookup_idx": { - "name": "events_delegating_item_lookup_idx", - "columns": [ - "thread_id", - "item_id", - "sequence", - "item_kind" - ], - "isUnique": false, - "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" - }, - "events_plan_steps_thread_sequence_idx": { - "name": "events_plan_steps_thread_sequence_idx", - "columns": [ - "thread_id", - "sequence" - ], - "isUnique": false, - "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" - }, - "events_parent_tool_call_thread_parent_sequence_idx": { - "name": "events_parent_tool_call_thread_parent_sequence_idx", - "columns": [ - "thread_id", - "parent_tool_call_id", - "sequence" - ], - "isUnique": false, - "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" - }, - "events_thread_type_item_kind_sequence_idx": { - "name": "events_thread_type_item_kind_sequence_idx", - "columns": [ - "thread_id", - "type", - "item_kind", - "sequence" - ], - "isUnique": false - }, - "events_background_task_thread_type_item_sequence_idx": { - "name": "events_background_task_thread_type_item_sequence_idx", - "columns": [ - "thread_id", - "type", - "item_id", - "sequence" - ], - "isUnique": false, - "where": "\"events\".\"item_kind\" = 'backgroundTask'" - }, - "events_thread_type_sequence_idx": { - "name": "events_thread_type_sequence_idx", - "columns": [ - "thread_id", - "type", - "sequence" - ], - "isUnique": false - }, - "events_thread_turn_type_item_sequence_idx": { - "name": "events_thread_turn_type_item_sequence_idx", - "columns": [ - "thread_id", - "turn_id", - "type", - "item_id", - "sequence" - ], - "isUnique": false - }, - "events_item_lifecycle_thread_item_sequence_idx": { - "name": "events_item_lifecycle_thread_item_sequence_idx", - "columns": [ - "thread_id", - "item_id", - "sequence" - ], - "isUnique": false, - "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" - }, - "events_environment_idx": { - "name": "events_environment_idx", - "columns": [ - "environment_id" - ], - "isUnique": false - }, - "events_provider_identity_idx": { - "name": "events_provider_identity_idx", - "columns": [ - "provider_thread_id", - "created_at" - ], - "isUnique": false, - "where": "\"events\".\"type\" = 'thread/identity'" - }, - "events_completed_item_truncation_idx": { - "name": "events_completed_item_truncation_idx", - "columns": [ - "item_kind", - "created_at", - "id" - ], - "isUnique": false, - "where": "\"events\".\"type\" = 'item/completed'" - }, - "events_thread_state_thread_sequence_idx": { - "name": "events_thread_state_thread_sequence_idx", - "columns": [ - "thread_id", - "sequence" - ], - "isUnique": false, - "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" - } - }, - "foreignKeys": { - "events_thread_id_threads_id_fk": { - "name": "events_thread_id_threads_id_fk", - "tableFrom": "events", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "events_environment_id_environments_id_fk": { - "name": "events_environment_id_environments_id_fk", - "tableFrom": "events", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "events_scope_shape_check": { - "name": "events_scope_shape_check", - "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" - } - } - }, - "host_daemon_sessions": { - "name": "host_daemon_sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "instance_id": { - "name": "instance_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_name": { - "name": "host_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data_dir": { - "name": "data_dir", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "protocol_version": { - "name": "protocol_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "heartbeat_interval_ms": { - "name": "heartbeat_interval_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lease_timeout_ms": { - "name": "lease_timeout_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "lease_expires_at": { - "name": "lease_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "closed_at": { - "name": "closed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "close_reason": { - "name": "close_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "host_daemon_sessions_host_status_idx": { - "name": "host_daemon_sessions_host_status_idx", - "columns": [ - "host_id", - "status" - ], - "isUnique": false - }, - "host_daemon_sessions_host_latest_idx": { - "name": "host_daemon_sessions_host_latest_idx", - "columns": [ - "host_id", - "updated_at", - "created_at", - "id" - ], - "isUnique": false - }, - "host_daemon_sessions_closed_prune_idx": { - "name": "host_daemon_sessions_closed_prune_idx", - "columns": [ - "status", - "closed_at", - "id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "host_daemon_sessions_host_id_hosts_id_fk": { - "name": "host_daemon_sessions_host_id_hosts_id_fk", - "tableFrom": "host_daemon_sessions", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "hosts": { - "name": "hosts", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connect_machine_id": { - "name": "connect_machine_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "machine_provider_id": { - "name": "machine_provider_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "launch_key": { - "name": "launch_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "machine_inputs": { - "name": "machine_inputs", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "machine_attempt": { - "name": "machine_attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "pending_log": { - "name": "pending_log", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "machine_operation_id": { - "name": "machine_operation_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "server_access_provider_id": { - "name": "server_access_provider_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "server_access_grant_id": { - "name": "server_access_grant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "resource": { - "name": "resource", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "phase": { - "name": "phase", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'active'" - }, - "suspended_at": { - "name": "suspended_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "suspend_retry_at": { - "name": "suspend_retry_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "remove_retry_at": { - "name": "remove_retry_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "teardown_attempt": { - "name": "teardown_attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "teardown_status": { - "name": "teardown_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "max_permission_mode": { - "name": "max_permission_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'full'" - }, - "destroyed_at": { - "name": "destroyed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_rejected_protocol_version": { - "name": "last_rejected_protocol_version", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "hosts_last_seen_idx": { - "name": "hosts_last_seen_idx", - "columns": [ - "last_seen_at" - ], - "isUnique": false - }, - "hosts_live_launch_key_idx": { - "name": "hosts_live_launch_key_idx", - "columns": [ - "launch_key" - ], - "isUnique": true, - "where": "\"hosts\".\"destroyed_at\" is null" - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugins": { - "name": "plugins", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provenance": { - "name": "provenance", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'direct'" - }, - "catalog_entry_id": { - "name": "catalog_entry_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "catalog_marketplace_name": { - "name": "catalog_marketplace_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'path'" - }, - "source_path": { - "name": "source_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_builtin_name": { - "name": "source_builtin_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_npm_package": { - "name": "source_npm_package", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_npm_registry": { - "name": "source_npm_registry", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_npm_requested_spec": { - "name": "source_npm_requested_spec", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_npm_spec_kind": { - "name": "source_npm_spec_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_url": { - "name": "source_git_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_subdirectory": { - "name": "source_git_subdirectory", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_requested_ref": { - "name": "source_git_requested_ref", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_ref_kind": { - "name": "source_git_ref_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_range": { - "name": "source_git_range", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_tag_prefix": { - "name": "source_git_tag_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_resolved_tag": { - "name": "source_git_resolved_tag", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "npm_resolved_version": { - "name": "npm_resolved_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "npm_integrity": { - "name": "npm_integrity", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_resolved_commit": { - "name": "git_resolved_commit", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_update_check_at": { - "name": "last_update_check_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "available_compatible_version": { - "name": "available_compatible_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "newest_incompatible_version": { - "name": "newest_incompatible_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "update_status_detail": { - "name": "update_status_detail", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_failure_version": { - "name": "last_failure_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_failure_at": { - "name": "last_failure_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_failure_detail": { - "name": "last_failure_detail", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_artifact_id": { - "name": "active_artifact_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "normalization_version": { - "name": "normalization_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "root_dir": { - "name": "root_dir", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "enabled": { - "name": "enabled", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "removed_at": { - "name": "removed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "installed_at": { - "name": "installed_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "plugins_active_artifact_id_plugin_artifacts_id_fk": { - "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", - "tableFrom": "plugins", - "tableTo": "plugin_artifacts", - "columnsFrom": [ - "active_artifact_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "maintenance_scan_cursors": { - "name": "maintenance_scan_cursors", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "policy": { - "name": "policy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "item_kind": { - "name": "item_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_created_at": { - "name": "last_created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "last_event_id": { - "name": "last_event_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "maintenance_scan_cursors_path_idx": { - "name": "maintenance_scan_cursors_path_idx", - "columns": [ - "policy", - "version", - "item_kind", - "output_path" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pending_interactions": { - "name": "pending_interactions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "origin_kind": { - "name": "origin_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'provider'" - }, - "turn_id": { - "name": "turn_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_thread_id": { - "name": "provider_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_request_id": { - "name": "provider_request_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "renderer_id": { - "name": "renderer_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "resolution": { - "name": "resolution", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_reason": { - "name": "status_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "resolved_at": { - "name": "resolved_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "pending_interactions_provider_request_idx": { - "name": "pending_interactions_provider_request_idx", - "columns": [ - "provider_id", - "provider_thread_id", - "provider_request_id" - ], - "isUnique": true - }, - "pending_interactions_thread_created_idx": { - "name": "pending_interactions_thread_created_idx", - "columns": [ - "thread_id", - "created_at" - ], - "isUnique": false - }, - "pending_interactions_thread_status_created_idx": { - "name": "pending_interactions_thread_status_created_idx", - "columns": [ - "thread_id", - "status", - "created_at" - ], - "isUnique": false - }, - "pending_interactions_status_created_idx": { - "name": "pending_interactions_status_created_idx", - "columns": [ - "status", - "created_at" - ], - "isUnique": false - }, - "pending_interactions_plugin_status_created_idx": { - "name": "pending_interactions_plugin_status_created_idx", - "columns": [ - "plugin_id", - "status", - "created_at" - ], - "isUnique": false - } - }, - "foreignKeys": { - "pending_interactions_thread_id_threads_id_fk": { - "name": "pending_interactions_thread_id_threads_id_fk", - "tableFrom": "pending_interactions", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_artifacts": { - "name": "plugin_artifacts", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "npm_resolved_version": { - "name": "npm_resolved_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_resolved_commit": { - "name": "git_resolved_commit", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_checkout_root": { - "name": "git_checkout_root", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "integrity": { - "name": "integrity", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "content_hash": { - "name": "content_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "validation_result": { - "name": "validation_result", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "validated_at": { - "name": "validated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "plugin_artifacts_plugin_idx": { - "name": "plugin_artifacts_plugin_idx", - "columns": [ - "plugin_id" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_kv": { - "name": "plugin_kv", - "columns": { - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "plugin_kv_plugin_id_key_pk": { - "columns": [ - "plugin_id", - "key" - ], - "name": "plugin_kv_plugin_id_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_marketplace_icons": { - "name": "plugin_marketplace_icons", - "columns": { - "marketplace_name": { - "name": "marketplace_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "entry_id": { - "name": "entry_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "etag": { - "name": "etag", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "content_hash": { - "name": "content_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "bytes": { - "name": "bytes", - "type": "blob", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "plugin_marketplace_icons_marketplace_name_entry_id_pk": { - "columns": [ - "marketplace_name", - "entry_id" - ], - "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_marketplaces": { - "name": "plugin_marketplaces", - "columns": { - "name": { - "name": "name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'https'" - }, - "manifest_url": { - "name": "manifest_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_git_ref": { - "name": "source_git_ref", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_git_commit": { - "name": "source_git_commit", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "manifest_json": { - "name": "manifest_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stats_json": { - "name": "stats_json", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "etag": { - "name": "etag", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_modified": { - "name": "last_modified", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_successful_refresh_at": { - "name": "last_successful_refresh_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_attempted_refresh_at": { - "name": "last_attempted_refresh_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_error": { - "name": "last_error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_schedules": { - "name": "plugin_schedules", - "columns": { - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cron": { - "name": "cron", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "next_run_at": { - "name": "next_run_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_run_at": { - "name": "last_run_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_status": { - "name": "last_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_error": { - "name": "last_error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "plugin_schedules_plugin_id_name_pk": { - "columns": [ - "plugin_id", - "name" - ], - "name": "plugin_schedules_plugin_id_name_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_settings": { - "name": "plugin_settings", - "columns": { - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "plugin_settings_plugin_id_key_pk": { - "columns": [ - "plugin_id", - "key" - ], - "name": "plugin_settings_plugin_id_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "plugin_state_snapshots": { - "name": "plugin_state_snapshots", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "from_artifact_id": { - "name": "from_artifact_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "to_artifact_id": { - "name": "to_artifact_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "snapshot_path": { - "name": "snapshot_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "database_path": { - "name": "database_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "state_path": { - "name": "state_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "secrets_path": { - "name": "secrets_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "registration_path": { - "name": "registration_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rollback_candidate_version": { - "name": "rollback_candidate_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rollback_source_fingerprint": { - "name": "rollback_source_fingerprint", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rollback_bb_version": { - "name": "rollback_bb_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rollback_sdk_version": { - "name": "rollback_sdk_version", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "rollback_detail": { - "name": "rollback_detail", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "retained_until": { - "name": "retained_until", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "plugin_state_snapshots_plugin_idx": { - "name": "plugin_state_snapshots_plugin_idx", - "columns": [ - "plugin_id" - ], - "isUnique": false - }, - "plugin_state_snapshots_retention_idx": { - "name": "plugin_state_snapshots_retention_idx", - "columns": [ - "retained_until" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_attachment_backfills": { - "name": "project_attachment_backfills", - "columns": { - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "phase": { - "name": "phase", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "thread_cursor": { - "name": "thread_cursor", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_cursor": { - "name": "input_cursor", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_id": { - "name": "input_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_sequence": { - "name": "input_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "attempted_at": { - "name": "attempted_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "project_attachment_backfills_project_id_projects_id_fk": { - "name": "project_attachment_backfills_project_id_projects_id_fk", - "tableFrom": "project_attachment_backfills", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_attachment_threads": { - "name": "project_attachment_threads", - "columns": { - "attachment_id": { - "name": "attachment_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_attachment_threads_thread_idx": { - "name": "project_attachment_threads_thread_idx", - "columns": [ - "thread_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "project_attachment_threads_attachment_id_project_attachments_id_fk": { - "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", - "tableFrom": "project_attachment_threads", - "tableTo": "project_attachments", - "columnsFrom": [ - "attachment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_attachment_threads_thread_id_threads_id_fk": { - "name": "project_attachment_threads_thread_id_threads_id_fk", - "tableFrom": "project_attachment_threads", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "project_attachment_threads_attachment_id_thread_id_pk": { - "columns": [ - "attachment_id", - "thread_id" - ], - "name": "project_attachment_threads_attachment_id_thread_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_attachments": { - "name": "project_attachments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stored_path": { - "name": "stored_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "original_name": { - "name": "original_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "size_bytes": { - "name": "size_bytes", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ready_at": { - "name": "ready_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deletion_claimed_at": { - "name": "deletion_claimed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "project_attachments_project_path_idx": { - "name": "project_attachments_project_path_idx", - "columns": [ - "project_id", - "stored_path" - ], - "isUnique": true - }, - "project_attachments_project_created_idx": { - "name": "project_attachments_project_created_idx", - "columns": [ - "project_id", - "created_at" - ], - "isUnique": false - }, - "project_attachments_deletion_idx": { - "name": "project_attachments_deletion_idx", - "columns": [ - "project_id", - "deletion_claimed_at", - "id" - ], - "isUnique": false, - "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL" - } - }, - "foreignKeys": { - "project_attachments_project_id_projects_id_fk": { - "name": "project_attachments_project_id_projects_id_fk", - "tableFrom": "project_attachments", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "project_attachments_size_check": { - "name": "project_attachments_size_check", - "value": "\"project_attachments\".\"size_bytes\" >= 0" - } - } - }, - "project_execution_defaults": { - "name": "project_execution_defaults", - "columns": { - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "service_tier": { - "name": "service_tier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reasoning_level": { - "name": "reasoning_level", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permission_mode": { - "name": "permission_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_execution_defaults_project_idx": { - "name": "project_execution_defaults_project_idx", - "columns": [ - "project_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_execution_defaults_project_id_projects_id_fk": { - "name": "project_execution_defaults_project_id_projects_id_fk", - "tableFrom": "project_execution_defaults", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "project_sources": { - "name": "project_sources", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owns_path": { - "name": "owns_path", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "is_default": { - "name": "is_default", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "project_sources_project_idx": { - "name": "project_sources_project_idx", - "columns": [ - "project_id" - ], - "isUnique": false - }, - "project_sources_host_idx": { - "name": "project_sources_host_idx", - "columns": [ - "host_id" - ], - "isUnique": false - }, - "project_sources_project_host_idx": { - "name": "project_sources_project_host_idx", - "columns": [ - "project_id", - "host_id" - ], - "isUnique": true - } - }, - "foreignKeys": { - "project_sources_project_id_projects_id_fk": { - "name": "project_sources_project_id_projects_id_fk", - "tableFrom": "project_sources", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "project_sources_host_id_hosts_id_fk": { - "name": "project_sources_host_id_hosts_id_fk", - "tableFrom": "project_sources", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "project_sources_shape_check": { - "name": "project_sources_shape_check", - "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" - } - } - }, - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'standard'" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "git_remote_url": { - "name": "git_remote_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_key": { - "name": "sort_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'V'" - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "projects_updated_idx": { - "name": "projects_updated_idx", - "columns": [ - "updated_at" - ], - "isUnique": false - }, - "projects_deleted_idx": { - "name": "projects_deleted_idx", - "columns": [ - "deleted_at" - ], - "isUnique": false - }, - "projects_sort_idx": { - "name": "projects_sort_idx", - "columns": [ - "sort_key", - "id" - ], - "isUnique": false - }, - "projects_personal_singleton_idx": { - "name": "projects_personal_singleton_idx", - "columns": [ - "kind" - ], - "isUnique": true, - "where": "\"projects\".\"kind\" = 'personal'" - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "prompt_history_entries": { - "name": "prompt_history_entries", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "request_sequence": { - "name": "request_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input": { - "name": "input", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "prompt_history_entries_thread_request_idx": { - "name": "prompt_history_entries_thread_request_idx", - "columns": [ - "thread_id", - "request_sequence" - ], - "isUnique": true - }, - "prompt_history_entries_project_scope_created_idx": { - "name": "prompt_history_entries_project_scope_created_idx", - "columns": [ - "project_id", - "scope", - "created_at", - "request_sequence", - "id" - ], - "isUnique": false - }, - "prompt_history_entries_thread_scope_created_idx": { - "name": "prompt_history_entries_thread_scope_created_idx", - "columns": [ - "thread_id", - "scope", - "created_at", - "request_sequence", - "id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "prompt_history_entries_project_id_projects_id_fk": { - "name": "prompt_history_entries_project_id_projects_id_fk", - "tableFrom": "prompt_history_entries", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "prompt_history_entries_thread_id_threads_id_fk": { - "name": "prompt_history_entries_thread_id_threads_id_fk", - "tableFrom": "prompt_history_entries", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "provider_model_catalogs": { - "name": "provider_model_catalogs", - "columns": { - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_key": { - "name": "scope_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fingerprint": { - "name": "fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "models_json": { - "name": "models_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "selected_only_models_json": { - "name": "selected_only_models_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "provider_model_catalogs_host_id_hosts_id_fk": { - "name": "provider_model_catalogs_host_id_hosts_id_fk", - "tableFrom": "provider_model_catalogs", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "provider_model_catalogs_host_id_provider_id_scope_key_pk": { - "columns": [ - "host_id", - "provider_id", - "scope_key" - ], - "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "queued_thread_messages": { - "name": "queued_thread_messages", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "client_submission_id": { - "name": "client_submission_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "system_notice": { - "name": "system_notice", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sender_thread_id": { - "name": "sender_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "origin": { - "name": "origin", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "origin_plugin_id": { - "name": "origin_plugin_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "requested_by_initiator": { - "name": "requested_by_initiator", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "requested_by_thread_id": { - "name": "requested_by_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "reasoning_level": { - "name": "reasoning_level", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "permission_mode": { - "name": "permission_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "service_tier": { - "name": "service_tier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "group_with_next": { - "name": "group_with_next", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "send_at": { - "name": "send_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "waiting_on": { - "name": "waiting_on", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "wait_holder": { - "name": "wait_holder", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "failure_reason": { - "name": "failure_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "payload_kind": { - "name": "payload_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'inline'" - }, - "retry_of_turn_request_id": { - "name": "retry_of_turn_request_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "retry_attempt": { - "name": "retry_attempt", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "retry_reason": { - "name": "retry_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "claimed_at": { - "name": "claimed_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "claim_token": { - "name": "claim_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_key": { - "name": "sort_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "queued_thread_messages_thread_created_idx": { - "name": "queued_thread_messages_thread_created_idx", - "columns": [ - "thread_id", - "created_at", - "id" - ], - "isUnique": false - }, - "queued_thread_messages_thread_sort_idx": { - "name": "queued_thread_messages_thread_sort_idx", - "columns": [ - "thread_id", - "sort_key", - "id" - ], - "isUnique": false - }, - "queued_thread_messages_due_idx": { - "name": "queued_thread_messages_due_idx", - "columns": [ - "send_at", - "id" - ], - "isUnique": false, - "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" - }, - "queued_thread_messages_wait_holder_idx": { - "name": "queued_thread_messages_wait_holder_idx", - "columns": [ - "wait_holder", - "id" - ], - "isUnique": false, - "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" - } - }, - "foreignKeys": { - "queued_thread_messages_thread_id_threads_id_fk": { - "name": "queued_thread_messages_thread_id_threads_id_fk", - "tableFrom": "queued_thread_messages", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "retained_event_outputs": { - "name": "retained_event_outputs", - "columns": { - "event_id": { - "name": "event_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "output_path": { - "name": "output_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "retained_event_outputs_expiry_idx": { - "name": "retained_event_outputs_expiry_idx", - "columns": [ - "expires_at", - "event_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "retained_event_outputs_event_id_events_id_fk": { - "name": "retained_event_outputs_event_id_events_id_fk", - "tableFrom": "retained_event_outputs", - "tableTo": "events", - "columnsFrom": [ - "event_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "system_experiments": { - "name": "system_experiments", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "terminal_sessions": { - "name": "terminal_sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "host_id": { - "name": "host_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "daemon_session_id": { - "name": "daemon_session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "initial_cwd": { - "name": "initial_cwd", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cols": { - "name": "cols", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "rows": { - "name": "rows", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "exit_code": { - "name": "exit_code", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "close_reason": { - "name": "close_reason", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_user_input_at": { - "name": "last_user_input_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "terminal_sessions_thread_status_updated_idx": { - "name": "terminal_sessions_thread_status_updated_idx", - "columns": [ - "thread_id", - "status", - "updated_at" - ], - "isUnique": false - }, - "terminal_sessions_environment_status_idx": { - "name": "terminal_sessions_environment_status_idx", - "columns": [ - "environment_id", - "status" - ], - "isUnique": false - }, - "terminal_sessions_host_status_idx": { - "name": "terminal_sessions_host_status_idx", - "columns": [ - "host_id", - "status" - ], - "isUnique": false - }, - "terminal_sessions_daemon_session_idx": { - "name": "terminal_sessions_daemon_session_idx", - "columns": [ - "daemon_session_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "terminal_sessions_thread_id_threads_id_fk": { - "name": "terminal_sessions_thread_id_threads_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_environment_id_environments_id_fk": { - "name": "terminal_sessions_environment_id_environments_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_host_id_hosts_id_fk": { - "name": "terminal_sessions_host_id_hosts_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "hosts", - "columnsFrom": [ - "host_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { - "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", - "tableFrom": "terminal_sessions", - "tableTo": "host_daemon_sessions", - "columnsFrom": [ - "daemon_session_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_conversation_outlines": { - "name": "thread_conversation_outlines", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "projection_key": { - "name": "projection_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "items_json": { - "name": "items_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "thread_conversation_outlines_thread_id_threads_id_fk": { - "name": "thread_conversation_outlines_thread_id_threads_id_fk", - "tableFrom": "thread_conversation_outlines", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_dynamic_context_file_states": { - "name": "thread_dynamic_context_file_states", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_key": { - "name": "file_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content_status": { - "name": "content_status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "content_hash": { - "name": "content_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "shown_at": { - "name": "shown_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_dynamic_context_file_states_thread_file_idx": { - "name": "thread_dynamic_context_file_states_thread_file_idx", - "columns": [ - "thread_id", - "file_key" - ], - "isUnique": true - } - }, - "foreignKeys": { - "thread_dynamic_context_file_states_thread_id_threads_id_fk": { - "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", - "tableFrom": "thread_dynamic_context_file_states", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_plugin_metadata": { - "name": "thread_plugin_metadata", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "metadata_json": { - "name": "metadata_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "thread_plugin_metadata_thread_id_threads_id_fk": { - "name": "thread_plugin_metadata_thread_id_threads_id_fk", - "tableFrom": "thread_plugin_metadata", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "thread_plugin_metadata_thread_id_plugin_id_pk": { - "columns": [ - "thread_id", - "plugin_id" - ], - "name": "thread_plugin_metadata_thread_id_plugin_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_pruning_cursors": { - "name": "thread_pruning_cursors", - "columns": { - "policy": { - "name": "policy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_thread_id": { - "name": "last_thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "''" - }, - "current_thread_id": { - "name": "current_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "step": { - "name": "step", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "sequence": { - "name": "sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "upper_sequence": { - "name": "upper_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "cycle": { - "name": "cycle", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "latest_root_sequence": { - "name": "latest_root_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "latest_context_sequence": { - "name": "latest_context_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "probe_event_id": { - "name": "probe_event_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "probe_phase": { - "name": "probe_phase", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "probe_sequence": { - "name": "probe_sequence", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "probe_witness_id": { - "name": "probe_witness_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_pruning_cursors_thread_idx": { - "name": "thread_pruning_cursors_thread_idx", - "columns": [ - "thread_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "thread_pruning_cursors_thread_id_threads_id_fk": { - "name": "thread_pruning_cursors_thread_id_threads_id_fk", - "tableFrom": "thread_pruning_cursors", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "thread_pruning_cursors_policy_scope_pk": { - "columns": [ - "policy", - "scope" - ], - "name": "thread_pruning_cursors_policy_scope_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": { - "thread_pruning_cursors_scope_check": { - "name": "thread_pruning_cursors_scope_check", - "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" - } - } - }, - "thread_search_segments": { - "name": "thread_search_segments", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_kind": { - "name": "source_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_key": { - "name": "source_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_seq": { - "name": "source_seq", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "text": { - "name": "text", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_search_segments_source_idx": { - "name": "thread_search_segments_source_idx", - "columns": [ - "thread_id", - "source_kind", - "source_key" - ], - "isUnique": true - }, - "thread_search_segments_thread_source_seq_idx": { - "name": "thread_search_segments_thread_source_seq_idx", - "columns": [ - "thread_id", - "source_seq" - ], - "isUnique": false - } - }, - "foreignKeys": { - "thread_search_segments_thread_id_threads_id_fk": { - "name": "thread_search_segments_thread_id_threads_id_fk", - "tableFrom": "thread_search_segments", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_sections": { - "name": "thread_sections", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "thread_sections_name_idx": { - "name": "thread_sections_name_idx", - "columns": [ - "name" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_submission_receipts": { - "name": "thread_submission_receipts", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "client_submission_id": { - "name": "client_submission_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "operation": { - "name": "operation", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "fingerprint": { - "name": "fingerprint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "result": { - "name": "result", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "thread_submission_receipts_thread_id_threads_id_fk": { - "name": "thread_submission_receipts_thread_id_threads_id_fk", - "tableFrom": "thread_submission_receipts", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "thread_submission_receipts_thread_id_client_submission_id_pk": { - "columns": [ - "thread_id", - "client_submission_id" - ], - "name": "thread_submission_receipts_thread_id_client_submission_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "thread_tabs": { - "name": "thread_tabs", - "columns": { - "thread_id": { - "name": "thread_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "tabs_json": { - "name": "tabs_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "revision": { - "name": "revision", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "thread_tabs_thread_id_threads_id_fk": { - "name": "thread_tabs_thread_id_threads_id_fk", - "tableFrom": "thread_tabs", - "tableTo": "threads", - "columnsFrom": [ - "thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "threads": { - "name": "threads", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "environment_id": { - "name": "environment_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "model_override": { - "name": "model_override", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reasoning_level_override": { - "name": "reasoning_level_override", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title_fallback": { - "name": "title_fallback", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "section_id": { - "name": "section_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'starting'" - }, - "startup_context": { - "name": "startup_context", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "parent_thread_id": { - "name": "parent_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lifecycle_owner_thread_id": { - "name": "lifecycle_owner_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "source_thread_id": { - "name": "source_thread_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "origin_kind": { - "name": "origin_kind", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "origin_plugin_id": { - "name": "origin_plugin_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "visibility": { - "name": "visibility", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'visible'" - }, - "archived_at": { - "name": "archived_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pinned_at": { - "name": "pinned_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pin_sort_key": { - "name": "pin_sort_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "storage_deleted_at": { - "name": "storage_deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_read_at": { - "name": "last_read_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "latest_attention_at": { - "name": "latest_attention_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "threads_project_id_idx": { - "name": "threads_project_id_idx", - "columns": [ - "project_id", - "id" - ], - "isUnique": false - }, - "threads_project_updated_idx": { - "name": "threads_project_updated_idx", - "columns": [ - "project_id", - "updated_at" - ], - "isUnique": false - }, - "threads_project_archived_deleted_idx": { - "name": "threads_project_archived_deleted_idx", - "columns": [ - "project_id", - "archived_at", - "deleted_at", - "id" - ], - "isUnique": false - }, - "threads_pin_sort_idx": { - "name": "threads_pin_sort_idx", - "columns": [ - "archived_at", - "deleted_at", - "pin_sort_key", - "id" - ], - "isUnique": false, - "where": "\"threads\".\"pinned_at\" IS NOT NULL" - }, - "threads_environment_idx": { - "name": "threads_environment_idx", - "columns": [ - "environment_id" - ], - "isUnique": false - }, - "threads_lifecycle_owner_idx": { - "name": "threads_lifecycle_owner_idx", - "columns": [ - "lifecycle_owner_thread_id" - ], - "isUnique": false - }, - "threads_parent_idx": { - "name": "threads_parent_idx", - "columns": [ - "parent_thread_id" - ], - "isUnique": false - }, - "threads_source_origin_idx": { - "name": "threads_source_origin_idx", - "columns": [ - "source_thread_id", - "origin_kind" - ], - "isUnique": false - }, - "threads_origin_plugin_archived_idx": { - "name": "threads_origin_plugin_archived_idx", - "columns": [ - "origin_plugin_id", - "archived_at" - ], - "isUnique": false - }, - "threads_section_archived_deleted_idx": { - "name": "threads_section_archived_deleted_idx", - "columns": [ - "section_id", - "archived_at", - "deleted_at", - "id" - ], - "isUnique": false - }, - "threads_archived_status_idx": { - "name": "threads_archived_status_idx", - "columns": [ - "archived_at", - "status" - ], - "isUnique": false - }, - "threads_environment_archived_deleted_idx": { - "name": "threads_environment_archived_deleted_idx", - "columns": [ - "environment_id", - "archived_at", - "deleted_at" - ], - "isUnique": false - }, - "threads_active_maintenance_idx": { - "name": "threads_active_maintenance_idx", - "columns": [ - "status" - ], - "isUnique": false, - "where": "\"threads\".\"deleted_at\" IS NULL" - } - }, - "foreignKeys": { - "threads_project_id_projects_id_fk": { - "name": "threads_project_id_projects_id_fk", - "tableFrom": "threads", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "threads_environment_id_environments_id_fk": { - "name": "threads_environment_id_environments_id_fk", - "tableFrom": "threads", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "threads_section_id_thread_sections_id_fk": { - "name": "threads_section_id_thread_sections_id_fk", - "tableFrom": "threads", - "tableTo": "thread_sections", - "columnsFrom": [ - "section_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "threads_parent_thread_id_threads_id_fk": { - "name": "threads_parent_thread_id_threads_id_fk", - "tableFrom": "threads", - "tableTo": "threads", - "columnsFrom": [ - "parent_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "threads_lifecycle_owner_thread_id_threads_id_fk": { - "name": "threads_lifecycle_owner_thread_id_threads_id_fk", - "tableFrom": "threads", - "tableTo": "threads", - "columnsFrom": [ - "lifecycle_owner_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - }, - "threads_source_thread_id_threads_id_fk": { - "name": "threads_source_thread_id_threads_id_fk", - "tableFrom": "threads", - "tableTo": "threads", - "columnsFrom": [ - "source_thread_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "ui_preferences": { - "name": "ui_preferences", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value_json": { - "name": "value_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "revision": { - "name": "revision", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ae20fdd6415..6a30ce673b3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -890,13 +890,6 @@ "when": 1789607725218, "tag": "0126_overconfident_vin_gonzales", "breakpoints": true - }, - { - "idx": 127, - "version": "6", - "when": 1789709582004, - "tag": "0127_volatile_meteorite", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 00a7bf06230..79a029c6a60 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -54,7 +54,6 @@ import { createOrderKeyAfter, createOrderKeyBetween } from "./order-keys.js"; import { queryInSqliteVariableBatches } from "./events.js"; export interface CreateQueuedThreadMessageInput { - clientSubmissionId?: string; threadId: string; content: PromptInput[]; senderThreadId?: string | null; @@ -619,7 +618,6 @@ export function createQueuedThreadMessageInTransaction( .values({ id, threadId: input.threadId, - clientSubmissionId: input.clientSubmissionId ?? null, content: JSON.stringify(input.content), senderThreadId: input.senderThreadId ?? null, origin: input.origin ?? null, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2cf74f4baf2..68c9b152330 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -954,28 +954,10 @@ export const promptHistoryEntries = sqliteTable( ], ); -export const threadSubmissionReceipts = sqliteTable( - "thread_submission_receipts", - { - threadId: text("thread_id") - .notNull() - .references(() => threads.id, { onDelete: "cascade" }), - clientSubmissionId: text("client_submission_id").notNull(), - operation: text("operation").$type<"send" | "queue">().notNull(), - fingerprint: text("fingerprint").notNull(), - result: text("result"), - createdAt: integer("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.threadId, table.clientSubmissionId] }), - ], -); - export const queuedThreadMessages = sqliteTable( "queued_thread_messages", { id: text("id").primaryKey(), - clientSubmissionId: text("client_submission_id"), // JSON `{ kind, subject }` when this row is one of core's own system // notices rather than somebody's message; NULL for every ordinary row. // Owned by the server, which is the only thing that writes or reads it. diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 8bb1f327eb7..4da166327e3 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -858,12 +858,10 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } function rewindMachineProvidersMigration(db: DbConnection): void { - db.$client.exec("DROP TABLE IF EXISTS thread_submission_receipts"); const queuedDispatchOrigin = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)") .all(); for (const name of [ - "client_submission_id", "origin", "origin_plugin_id", "requested_by_initiator", diff --git a/packages/domain/src/feature-flags.ts b/packages/domain/src/feature-flags.ts index 89f003bace4..ae8bf32142d 100644 --- a/packages/domain/src/feature-flags.ts +++ b/packages/domain/src/feature-flags.ts @@ -2,7 +2,6 @@ import { z } from "zod"; export const featureFlagsSchema = z.object({ placeholder: z.boolean(), - durableMessageDelivery: z.boolean().optional(), timelineWindowEventBudget: z.number().int().positive(), }); export type FeatureFlags = z.infer; diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index e6dc0e7cdde..6bae9ee9f76 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.107"; +export const PLUGIN_SDK_VERSION = "0.4.106"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 0732e0a937c..405edfee51a 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -335,11 +335,7 @@ export const threadPullRequestSchema = z .strict(); export type ThreadPullRequest = z.infer; -export const clientSubmissionIdSchema = z.string().min(1).max(128); - export const threadQueuedMessageSchema = z.object({ - clientSubmissionId: clientSubmissionIdSchema.optional(), - replayed: z.boolean().optional(), id: z.string(), origin: threadCreateOriginSchema.nullable(), originPluginId: z.string().nullable(), diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 28fd6a30e58..fbf0990e803 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.107", + "version": "0.4.106", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 34dd9b9d4dc..292420f4382 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -264,7 +264,6 @@ export interface ThreadDeleteArgs extends DeleteThreadRequest { } export interface ThreadSendArgs extends SendMessageRequest { - signal?: AbortSignal; threadId: string; } @@ -313,7 +312,6 @@ export interface ThreadQueuedMessageArgs { } export interface ThreadQueuedMessageCreateArgs extends CreateQueuedMessageRequest { - signal?: AbortSignal; threadId: string; } @@ -675,7 +673,6 @@ function updateJson(args: ThreadUpdateArgs): UpdateThreadRequest { function sendJson(args: ThreadSendArgs): SendMessageRequest { return { - clientSubmissionId: args.clientSubmissionId, input: args.input, mode: args.mode, model: args.model, @@ -968,15 +965,12 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }; const queuedMessages: ThreadQueuedMessagesArea = { async create(input) { - const { threadId, signal, ...json } = input; + const { threadId, ...json } = input; return transport.readJson( - transport.api.v1.threads[":id"]["queued-messages"].$post( - { - param: { id: threadId }, - json, - }, - ...signalRequestArgs(signal), - ), + transport.api.v1.threads[":id"]["queued-messages"].$post({ + param: { id: threadId }, + json, + }), ); }, async delete(input) { @@ -1294,13 +1288,10 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }, async send(input) { return transport.readJson( - transport.api.v1.threads[":id"].send.$post( - { - param: { id: input.threadId }, - json: sendJson(input), - }, - ...signalRequestArgs(input.signal), - ), + transport.api.v1.threads[":id"].send.$post({ + param: { id: input.threadId }, + json: sendJson(input), + }), ); }, async retry(input) { diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 2df3f3b0061..e5fe470a80d 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -296,46 +296,6 @@ describe("@bb/sdk", () => { ]); }); - it("forwards submission IDs and abort signals without serializing the signal", async () => { - const received: Array<{ - signal: AbortSignal | null | undefined; - body: string | undefined; - }> = []; - const fetch: FetchImplementation = async (_input, init) => { - received.push({ signal: init?.signal, body: bodyText(init) }); - return jsonResponse({ body: { ok: true, delivery: "sent" } }); - }; - const sdk = createBbSdk({ - transport: createHttpTransport({ - baseUrl: "http://bb.test", - fetch, - runtime: "node", - }), - }); - const signal = new AbortController().signal; - const input = [{ type: "text" as const, text: "keep this", mentions: [] }]; - await sdk.threads.send({ - threadId: "thr_submission", - input, - mode: "start", - clientSubmissionId: "send_nanoid", - signal, - }); - await sdk.threads.queuedMessages.create({ - threadId: "thr_submission", - input, - clientSubmissionId: "queue_nanoid", - signal, - }); - expect(received.map((request) => request.signal)).toEqual([signal, signal]); - expect(received.map((request) => JSON.parse(request.body ?? "{}"))).toEqual( - [ - { input, mode: "start", clientSubmissionId: "send_nanoid" }, - { input, clientSubmissionId: "queue_nanoid" }, - ], - ); - }); - it("forwards read abort signals to fetch", async () => { const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined; diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 688cce2a877..6e3b33f2dd4 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -24,7 +24,6 @@ import { threadOriginKindSchema, threadListEntrySchema, threadQueuedMessageSchema, - clientSubmissionIdSchema, threadSearchSourceKindSchema, threadStatusSchema, threadTimelineActivePromptModeSchema, @@ -251,9 +250,7 @@ const sendMessageRequestFieldsSchema = z.object({ sendAt: z.number().int().nonnegative().optional(), }); -export const sendMessageRequestSchema = sendMessageRequestFieldsSchema.extend({ - clientSubmissionId: clientSubmissionIdSchema.optional(), -}); +export const sendMessageRequestSchema = sendMessageRequestFieldsSchema; export type SendMessageRequest = z.infer; /** @@ -274,18 +271,10 @@ export type SendMessageDelivery = z.infer; * for now" would invite every caller to check fields that cannot exist. */ export const sendMessageResponseSchema = z.discriminatedUnion("delivery", [ - z.object({ - ok: z.literal(true), - delivery: z.literal("sent"), - clientSubmissionId: clientSubmissionIdSchema.optional(), - replayed: z.boolean().optional(), - turnRequestId: clientTurnRequestIdSchema.optional(), - }), + z.object({ ok: z.literal(true), delivery: z.literal("sent") }), z.object({ ok: z.literal(true), delivery: z.literal("queued"), - clientSubmissionId: clientSubmissionIdSchema.optional(), - replayed: z.boolean().optional(), queuedMessage: threadQueuedMessageSchema, }), ]); @@ -372,7 +361,6 @@ export const sendQueuedMessageModeSchema = z.enum(["auto", "steer"]); export type SendQueuedMessageMode = z.infer; export const createQueuedMessageRequestSchema = z.object({ - clientSubmissionId: clientSubmissionIdSchema.optional(), input: z.array(promptInputSchema).min(1), model: z.string().optional(), serviceTier: serviceTierSchema.optional(), diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 816571ad523..602d1dae150 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -49,22 +49,9 @@ interface OptionalServerFieldGroup { reason: string; } -const OPTIONAL_SERVER_FIELD_GROUP_LIMIT = 46; +const OPTIONAL_SERVER_FIELD_GROUP_LIMIT = 45; const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ - { - reason: - "Submission correlation fields are absent on older servers and requests without a client submission ID; replayed appears only on a receipt replay.", - fields: [ - "createQueuedMessageRequestSchema.clientSubmissionId", - "sendMessageRequestSchema.clientSubmissionId", - "sendQueuedMessageResponseSchema.clientSubmissionId", - "sendQueuedMessageResponseSchema.replayed", - "sendQueuedMessageResponseSchema.turnRequestId", - "sendQueuedMessageResponseSchema.queuedMessage.clientSubmissionId", - "sendQueuedMessageResponseSchema.queuedMessage.replayed", - ], - }, { reason: "A submitted plugin form leaves on its row only what the plugin's describeSubmission returned, and the whole description is absent when the plugin declares no describeSubmission or when that call throws or times out. Within one, an absent title means the presentation's completed label stands, an absent detail means the title is the whole row, and an absent payload means the row renders without handing anything to the plugin's own timeline renderer. bb never stores the form's payload or the submitted value, so these fields are the entire record of what happened.", @@ -1122,36 +1109,6 @@ describe("server-contract canonical schemas", () => { ).toMatchObject({ ownsPath: false }); }); - it("accepts optional bounded nanoid submission IDs and legacy response shapes", () => { - for (const schema of [ - sendMessageRequestSchema, - createQueuedMessageRequestSchema, - ]) { - const payload = { - input: [{ type: "text", text: "keep this" }], - mode: "start", - }; - expect(schema.parse(payload)).not.toHaveProperty("clientSubmissionId"); - expect( - schema.parse({ ...payload, clientSubmissionId: "nanoid_With-dashes" }), - ).toHaveProperty("clientSubmissionId", "nanoid_With-dashes"); - for (const clientSubmissionId of ["", "x".repeat(129)]) - expect(() => - schema.parse({ ...payload, clientSubmissionId }), - ).toThrow(); - } - expect( - contract.sendMessageResponseSchema.parse({ ok: true, delivery: "sent" }), - ).toEqual({ ok: true, delivery: "sent" }); - expect(() => - contract.editMessageRequestSchema.parse({ - operationId: "edit", - input: [{ type: "text", text: "edit" }], - clientSubmissionId: "not-supported", - }), - ).toThrow(); - }); - it("parses request contracts", () => { expect( createThreadRequestSchema.parse({ diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index c19362eddac..0329c745ac1 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -255,7 +255,6 @@ Messaging: Read the message from a file; `-` reads stdin. Use this for multi-line or Markdown messages: inside double quotes the shell runs `backticks` and $(...) before bb sees them - --submission-id Stable ID for retrying an ordinary follow-up --mode Message mode: steer (default), queue, or auto --model Model override for this turn --reasoning-level Reasoning level override @@ -277,23 +276,6 @@ Messaging: its `id`, `waitingOn`, and `sendAt`. A deferred message waits for a thread that failed while it was deferred, and delivers when the thread is retried. - --submission-id uses a stable 1–128 character ID for an ordinary message. - With it, tell defaults to queue mode: a delayed message stays a follow-up - instead of steering a later turn. Steering and conversation commands cannot - use submission IDs. Retrying the same ID and identical payload replays its - original acceptance, even after queue consumption, archiving, or restart; - changed content/options return a conflict. Reuse uploaded attachment paths - and an absolute --send-at timestamp across retries. - SDK callers pass clientSubmissionId and optional signal to threads.send or - threads.queuedMessages.create. Confirm system config featureFlags contains - durableMessageDelivery: true before automatic retries; absence means stop - replaying and retain the message. A replayed queued response is historical: - refresh queue state instead of inserting it as a current row. - A definitive error may include details.submission with the matching - clientSubmissionId and acceptance: rejected after the attempt settles; only - that marker resolves uncertainty from an earlier lost response. Ordinary - status/code alone does not prove that the earlier request was unaccepted. - --plan sends the same structured /plan command the composer's plan action sends, so the agent proposes a plan for approval before executing (Claude Code and Codex threads). Plain "/plan ..." text is not recognized; it reaches @@ -367,7 +349,7 @@ Interactions: Queued messages: bb thread queue list [] [--wait-holder plugin:] - bb thread queue create [--submission-id ] + bb thread queue create bb thread queue update [--file ] [--image ] bb thread queue send [--mode auto|steer] bb thread queue reorder [--after ] [--before ] diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 6fee342fb9a..88984cd9adf 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -17,16 +17,6 @@ stdin): inside double quotes the shell runs `backticks` and `$(...)` before bb sees the message. `bb thread edit-message`, `bb thread queue create`, and `bb thread queue update` take `--message-file` too. -- For connection-failure retries of ordinary messages, pass the same - `--submission-id ` (1–128 characters) to `bb thread tell` or - `bb thread queue create`, with identical content/options. Tell defaults to - queue mode when this flag is supplied; steering and conversation commands - are excluded. Preserve uploaded attachment paths and absolute scheduled - times across retries. SDK equivalents accept `clientSubmissionId` and - `signal`. Only retry automatically while system config advertises - `featureFlags.durableMessageDelivery: true`; otherwise retain and pause. - A `replayed` queued response describes historical acceptance, so refresh the - queue instead of inserting that response as a live row. - Add `--plan` to `bb thread spawn` or `bb thread tell` to send the prompt as the provider's structured `/plan` action: the agent proposes a plan for approval before executing when supported by the provider. Plain `/plan ...` text is diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1df3845fc12..1b4b3ff605d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,9 +351,6 @@ importers: cacache: specifier: ^20.0.4 version: 20.0.4 - fake-indexeddb: - specifier: ^6.2.5 - version: 6.2.5 lightningcss: specifier: ^1.32.0 version: 1.32.0 @@ -12100,10 +12097,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fake-indexeddb@6.2.5: - resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} - engines: {node: '>=18'} - fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -24890,8 +24883,6 @@ snapshots: extend@3.0.2: {} - fake-indexeddb@6.2.5: {} - fast-copy@4.0.2: {} fast-deep-equal@3.1.3: {} From 8153448f6c38dce3d0a8319fb46267b71aae8c1e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 13:01:48 -0400 Subject: [PATCH 19/29] Wait for queue acceptance before asserting draft clear --- .../thread/embedded-chat/EmbeddedThreadChat.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index a45a9e80333..55b8fdf0e34 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -519,9 +519,11 @@ describe("EmbeddedThreadChat", () => { }), ); expect(mocks.sendThreadMessageMutateAsync).not.toHaveBeenCalled(); - expect( - screen.getByTestId("embedded-chat-composer").value, - ).toBe(""); + await vi.waitFor(() => { + expect( + screen.getByTestId("embedded-chat-composer").value, + ).toBe(""); + }); }); it("keeps a queued draft when the request fails after the composer closes", async () => { From 9ae913a4d8402ddd91a8c895630519107ff5ae7e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:43:44 -0400 Subject: [PATCH 20/29] Retain pending submissions in the existing queue drawer and accept retries once --- apps/app/src/App.tsx | 2 + .../promptbox/banner/QueuedMessagesList.tsx | 55 +- .../embedded-chat/EmbeddedThreadChat.tsx | 55 +- .../thread-runtime-cache-owner.ts | 2 +- apps/app/src/hooks/queries/thread-queries.ts | 14 +- apps/app/src/hooks/usePromptDraftStorage.ts | 9 + apps/app/src/hooks/useRetainThreadMessage.ts | 34 + .../lib/PendingThreadMessagesSync.test.tsx | 66 + .../app/src/lib/PendingThreadMessagesSync.tsx | 95 + .../src/lib/pending-thread-messages.test.tsx | 62 + apps/app/src/lib/pending-thread-messages.ts | 127 + .../thread-detail/ThreadDetailPromptArea.tsx | 33 +- apps/cli/src/commands/thread/actions.ts | 10 + apps/cli/src/commands/thread/organization.ts | 8 + apps/server/src/routes/system.ts | 1 + .../src/services/threads/queued-messages.ts | 87 +- .../services/threads/thread-send-request.ts | 34 + .../test/threads/thread-send-dispatch.test.ts | 36 + .../drizzle/0127_parched_roxanne_simpson.sql | 8 + packages/db/drizzle/meta/0127_snapshot.json | 5061 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/data/queued-thread-messages.ts | 3 +- packages/db/src/schema.ts | 13 + packages/domain/src/thread.ts | 1 + packages/sdk/src/areas/threads.ts | 27 +- packages/server-contract/src/api/system.ts | 1 + packages/server-contract/src/api/threads.ts | 2 + .../src/templates/bb-guide-threads.md | 7 +- .../bb-cli/references/thread-operation.md | 2 + 29 files changed, 5816 insertions(+), 46 deletions(-) create mode 100644 apps/app/src/hooks/useRetainThreadMessage.ts create mode 100644 apps/app/src/lib/PendingThreadMessagesSync.test.tsx create mode 100644 apps/app/src/lib/PendingThreadMessagesSync.tsx create mode 100644 apps/app/src/lib/pending-thread-messages.test.tsx create mode 100644 apps/app/src/lib/pending-thread-messages.ts create mode 100644 packages/db/drizzle/0127_parched_roxanne_simpson.sql create mode 100644 packages/db/drizzle/meta/0127_snapshot.json diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 4bc8707e8c0..98237a93073 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -15,6 +15,7 @@ import { RouteNavigationIndicator } from "./components/ui/route-navigation-indic import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; +import { PendingThreadMessagesSync } from "@/lib/PendingThreadMessagesSync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -433,6 +434,7 @@ export function App() { + entry.row.id === queuedMessage.id, + ) || queuedMessageHasWaitLine(queuedMessage) || queuedMessage.id === processingMessageId ? queuedMessage.initiator === "agent" && @@ -703,17 +711,22 @@ function QueuedMessageWaitLine({ pluginDisplayName: string; queuedMessage: ThreadQueuedMessage; }) { + const pending = usePendingThreadMessages().find( + (entry) => entry.row.id === queuedMessage.id, + ); const now = useSecondTick(); - const label = describeQueuedMessageWait({ - failureReason: queuedMessage.failureReason, - now, - payload: queuedMessage.payload, - pluginDisplayName, - sendAt: queuedMessage.sendAt, - waitingOn: queuedMessage.waitingOn, - }); + const label = pending + ? (pending.error ?? "Waiting for server confirmation") + : describeQueuedMessageWait({ + failureReason: queuedMessage.failureReason, + now, + payload: queuedMessage.payload, + pluginDisplayName, + sendAt: queuedMessage.sendAt, + waitingOn: queuedMessage.waitingOn, + }); if (label === null) return null; - const failed = queuedMessage.failureReason !== null; + const failed = pending?.error != null || queuedMessage.failureReason !== null; const icon = queuedMessageWaitIcon(queuedMessage); const countdownInstant = queuedMessageCountdownInstant(queuedMessage); const countdown = @@ -782,6 +795,15 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ compact, isGroupBoundary, }: QueuedMessageRowProps) { + const pending = usePendingThreadMessages().find( + (entry) => entry.row.id === queuedMessage.id, + ); + sendDisabled ||= pending !== undefined; + dragDisabled ||= pending !== undefined; + actionDisabled ||= pending !== undefined && pending.error === null; + const deleteMessage = pending + ? () => removePendingThreadMessage(queuedMessage.id) + : () => onDelete(queuedMessage.id); const actionsRef = useRef(null); const focusActionsOnExpandRef = useRef(false); useLayoutEffect(() => { @@ -800,7 +822,8 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ ? queuedMessage.waitingOn.pluginId : "", ); - const hasWaitLine = queuedMessageHasWaitLine(queuedMessage); + const hasWaitLine = + pending !== undefined || queuedMessageHasWaitLine(queuedMessage); const sendAllowed = sendAction === "steer-when-ready" || isQueuedMessageSendNowAllowed(queuedMessage.waitingOn); @@ -997,7 +1020,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ "shrink-0 text-muted-foreground", compact ? "size-7" : "size-8", )} - disabled={actionDisabled} + disabled={actionDisabled || pending !== undefined} onClick={() => onEdit({ queuedMessageId: queuedMessage.id, @@ -1025,7 +1048,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ compact ? "size-7" : "size-8", )} disabled={actionDisabled} - onClick={() => onDelete(queuedMessage.id)} + onClick={deleteMessage} aria-label={`Delete queued message ${index + 1}`} > @@ -1240,6 +1263,7 @@ export function QueuedMessagesList({ onEdit, onDelete, }: QueuedMessagesListProps) { + const pendingMessages = usePendingThreadMessages(); const senderThreadMetadataById = useSenderThreadMetadataById(); const processingLabel = processingAction === "edit" @@ -1455,7 +1479,12 @@ export function QueuedMessagesList({ ]; }, [groupBoundaryIndex, orderedMessages]); const sortingDisabled = - actionDisabled || processingMessageId !== null || queuedMessages.length < 2; + actionDisabled || + processingMessageId !== null || + queuedMessages.length < 2 || + queuedMessages.some((row) => + pendingMessages.some((entry) => entry.row.id === row.id), + ); const sortableIds = useMemo( () => inlineEditor diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index d9538cc3f26..879f16a8c28 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -55,6 +55,7 @@ import { } from "@/hooks/queries/thread-queries"; import { useThreadDefaultExecutionOptions } from "@/hooks/queries/thread-default-execution-options-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useRetainThreadMessage } from "@/hooks/useRetainThreadMessage"; import { useCreateThreadQueuedMessage, useSendThreadMessage, @@ -231,6 +232,7 @@ function EmbeddedThreadChatWithComposer({ composer, }: EmbeddedThreadChatComposerModeProps) { const systemConfigQuery = useSystemConfig(); + const pendingMessages = useRetainThreadMessage(); const steerActiveThreadOnEnter = systemConfigQuery.data?.generalSettings.steerActiveThreadOnEnter ?? defaultAppSettings.steerActiveThreadOnEnter; @@ -441,17 +443,21 @@ function EmbeddedThreadChatWithComposer({ const submitMode = useMemo( () => - buildSideChatSubmitMode({ - childThreadId: threadId, - hasPendingInteraction: hasComposerBlockingPendingInteraction, - isDefaultExecutionOptionsLoading, - isPendingInteractionsInitialLoading: - pendingInteractionsInitialLoading || pendingInteractionsUnavailable, - isStopRequested, - onStop: handleStopThread, - runtimeDisplayStatus: displayStatus, - }), + !pendingMessages.connected && !shouldQueueFollowUpMessage(displayStatus) + ? { kind: "blocked", reason: "unavailable" } + : buildSideChatSubmitMode({ + childThreadId: threadId, + hasPendingInteraction: hasComposerBlockingPendingInteraction, + isDefaultExecutionOptionsLoading, + isPendingInteractionsInitialLoading: + pendingInteractionsInitialLoading || + pendingInteractionsUnavailable, + isStopRequested, + onStop: handleStopThread, + runtimeDisplayStatus: displayStatus, + }), [ + pendingMessages.connected, displayStatus, hasComposerBlockingPendingInteraction, handleStopThread, @@ -496,6 +502,32 @@ function EmbeddedThreadChatWithComposer({ return; } const isQueuingMessage = shouldQueueFollowUpMessage(displayStatus); + if (!isQueuingMessage && !pendingMessages.connected) return; + try { + if ( + pendingMessages.retain({ + request: { + id: threadId, + input: submittedInput, + ...executionRequestFields, + }, + operation: isQueuingMessage ? "queue" : "send", + draft: submittedDraft, + draftKey: promptDraft.storageKey, + }) + ) { + promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); + return; + } + } catch (error) { + showMutationErrorToast({ + error, + fallbackMessage: "Could not save message on this device", + lifecycleOperation: "queue_message", + }); + return; + } if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); setIsTurnSubmitting(true); @@ -522,6 +554,9 @@ function EmbeddedThreadChatWithComposer({ } }); }, [ + pendingMessages, + threadId, + executionRequestFields, currentPromptDraft, currentPromptDraftInput, defaultSendOrQueueInput, diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts index 695a69c1c41..97a3011ea67 100644 --- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.ts @@ -390,7 +390,7 @@ function getCachedDefaultExecutionOptions( ); } -function buildOptimisticQueuedMessage({ +export function buildOptimisticQueuedMessage({ createdAt, queryClient, request, diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 0922143f951..f35c50a58c9 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1,4 +1,5 @@ import { prependOlderTimelineRows } from "@bb/client-core"; +import { usePendingThreadMessages } from "@/lib/pending-thread-messages"; import { useInfiniteQuery, useQuery, @@ -702,10 +703,11 @@ export function useThreadQueuedMessages( id: string, options?: ThreadQueuedMessagesQueryOptions, ) { + const pending = usePendingThreadMessages(); const enabled = (options?.enabled ?? true) && Boolean(id); useThreadDetailRealtimeSubscription(id, { enabled }); - return useQuery({ + const query = useQuery({ queryKey: threadQueuedMessagesQueryKey(id), queryFn: ({ signal }) => sdk.threads.queuedMessages.list({ @@ -717,6 +719,16 @@ export function useThreadQueuedMessages( refetchOnWindowFocus: true, staleTime: options?.staleTime, }); + const data = useMemo(() => { + const local = pending.filter((entry) => entry.request.id === id); + if (local.length === 0) return query.data; + const localIds = new Set(local.map((entry) => entry.row.id)); + return [ + ...(query.data ?? []).filter((row) => !localIds.has(row.id)), + ...local.map((entry) => entry.row), + ]; + }, [pending, id, query.data]); + return { ...query, data }; } export function useThreadPromptHistory( diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index 4662f8e1b9f..fafecff3b39 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -32,6 +32,15 @@ interface PromptDraftWriteOptions { } const EMPTY_PROMPT_DRAFT = emptyPromptDraftState(); + +export function clearStoredPromptDraftIfCurrentMatches( + storageKey: string, + expectedDraft: PromptDraftState, +): void { + if (arePromptDraftStatesEqual(readPromptDraft(storageKey), expectedDraft)) { + writePromptDraft(storageKey, EMPTY_PROMPT_DRAFT, { persist: "immediate" }); + } +} const promptDraftCache = new Map(); const promptDraftSubscribers = new Map>(); const pendingPromptDraftStorageKeys = new Set(); diff --git a/apps/app/src/hooks/useRetainThreadMessage.ts b/apps/app/src/hooks/useRetainThreadMessage.ts new file mode 100644 index 00000000000..161650ddaea --- /dev/null +++ b/apps/app/src/hooks/useRetainThreadMessage.ts @@ -0,0 +1,34 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useSystemConfig } from "./queries/system-queries"; +import { useServerConnectionState } from "./useServerConnectionState"; +import { retainThreadMessage } from "@/lib/pending-thread-messages"; +import { notifyComposerSubmitted } from "@/lib/composer-submissions"; + +export function useRetainThreadMessage() { + const queryClient = useQueryClient(); + const { data: config } = useSystemConfig(); + const connection = useServerConnectionState(); + return { + connected: !config?.messageSubmissionKeys || connection === "connected", + retain( + args: Omit[0], "queryClient">, + ): boolean { + if ( + !config?.messageSubmissionKeys || + args.request.input.some( + (block) => + block.type === "text" && + block.mentions.some( + (mention) => + mention.resource.kind === "command" && + mention.resource.source === "command", + ), + ) + ) + return false; + retainThreadMessage({ ...args, queryClient }); + notifyComposerSubmitted({ kind: "thread", threadId: args.request.id }); + return true; + }, + }; +} diff --git a/apps/app/src/lib/PendingThreadMessagesSync.test.tsx b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx new file mode 100644 index 00000000000..ea25ba6640f --- /dev/null +++ b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { emptyPromptDraftState } from "@bb/client-core"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { PendingThreadMessagesSync } from "./PendingThreadMessagesSync"; +import { + getPendingThreadMessages, + retainThreadMessage, +} from "./pending-thread-messages"; +import { sdk } from "./sdk"; + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ data: { messageSubmissionKeys: true } }), +})); +vi.mock("@/hooks/useServerConnectionState", () => ({ + useServerConnectionState: () => "connected", +})); +vi.mock("./ws", () => ({ + wsManager: { getConnectionState: () => "connected" }, +})); +vi.mock("./sdk", async (importOriginal) => ({ + ...(await importOriginal()), + sdk: { threads: { queuedMessages: { create: vi.fn() } } }, +})); + +afterEach(() => { + cleanup(); + localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + vi.clearAllMocks(); +}); + +it("retains an uncertain request and retries with the same submission key until confirmed", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + vi.mocked(sdk.threads.queuedMessages.create) + .mockRejectedValueOnce(new TypeError("Response lost")) + .mockImplementationOnce(async (request) => ({ + ...getPendingThreadMessages()[0]!.row, + clientSubmissionId: request.clientSubmissionId, + })); + render(, { wrapper }); + act(() => + retainThreadMessage({ + queryClient, + operation: "queue", + draftKey: null, + draft: { ...emptyPromptDraftState(), text: "saved locally" }, + request: { + id: "thread-1", + input: [{ type: "text", text: "saved locally", mentions: [] }], + }, + }), + ); + await waitFor(() => + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), + ); + expect(getPendingThreadMessages()).toHaveLength(1); + await waitFor(() => expect(getPendingThreadMessages()).toHaveLength(0), { + timeout: 3000, + }); + const calls = vi.mocked(sdk.threads.queuedMessages.create).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[1]![0].clientSubmissionId).toBe(calls[0]![0].clientSubmissionId); + queryClient.clear(); +}); diff --git a/apps/app/src/lib/PendingThreadMessagesSync.tsx b/apps/app/src/lib/PendingThreadMessagesSync.tsx new file mode 100644 index 00000000000..b8448da7d82 --- /dev/null +++ b/apps/app/src/lib/PendingThreadMessagesSync.tsx @@ -0,0 +1,95 @@ +import { useEffect } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useServerConnectionState } from "@/hooks/useServerConnectionState"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { threadQueuedMessagesQueryKey } from "@/hooks/queries/query-keys"; +import { BbHttpError, sdk } from "./sdk"; +import { wsManager } from "./ws"; +import { + removePendingThreadMessage, + savePendingThreadMessage, + usePendingThreadMessages, + type PendingThreadMessage, +} from "./pending-thread-messages"; + +function retryable(error: unknown): boolean { + return ( + !(error instanceof BbHttpError) || + error.status >= 500 || + error.status === 429 || + error.status === 408 + ); +} + +function PendingDelivery({ entry }: { entry: PendingThreadMessage }) { + const queryClient = useQueryClient(); + const connection = useServerConnectionState(); + const mutation = useMutation({ + mutationKey: ["deliver-pending-thread-message", entry.row.id], + meta: { showErrorToast: false }, + retry: (_count, error) => + retryable(error) && wsManager.getConnectionState() === "connected", + mutationFn: async () => { + if (wsManager.getConnectionState() !== "connected") + throw new Error("Waiting for connection"); + const { id, ...request } = entry.request; + const signal = AbortSignal.timeout(30_000); + const result = + entry.operation === "queue" + ? await sdk.threads.queuedMessages.create({ + ...request, + threadId: id, + signal, + }) + : await sdk.threads + .send({ + ...request, + threadId: id, + mode: "queue-if-active", + signal, + }) + .then((result) => + result.delivery === "queued" ? result.queuedMessage : null, + ); + if (result?.clientSubmissionId !== request.clientSubmissionId) + throw new Error("Waiting for server confirmation"); + await queryClient.invalidateQueries({ + queryKey: threadQueuedMessagesQueryKey(id), + }); + removePendingThreadMessage(entry.row.id); + }, + onError: (error) => { + if (!retryable(error)) + savePendingThreadMessage({ + ...entry, + error: + error instanceof Error ? error.message : "Message was rejected", + }); + }, + }); + const { mutate } = mutation; + useEffect(() => { + if ( + connection === "connected" && + queryClient.isMutating({ + mutationKey: ["deliver-pending-thread-message", entry.row.id], + }) === 0 + ) + mutate(); + }, [connection, mutate, queryClient, entry.row.id]); + return null; +} + +export function PendingThreadMessagesSync() { + const entries = usePendingThreadMessages(); + const { data: config } = useSystemConfig(); + if (!config?.messageSubmissionKeys) return null; + const threads = new Set(); + return entries + .filter((entry) => { + if (entry.error || threads.has(entry.request.id)) return false; + threads.add(entry.request.id); + return true; + }) + .map((entry) => ); +} diff --git a/apps/app/src/lib/pending-thread-messages.test.tsx b/apps/app/src/lib/pending-thread-messages.test.tsx new file mode 100644 index 00000000000..6796d7277a6 --- /dev/null +++ b/apps/app/src/lib/pending-thread-messages.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { emptyPromptDraftState } from "@bb/client-core"; +import { + getPendingThreadMessages, + retainThreadMessage, + removePendingThreadMessage, +} from "./pending-thread-messages"; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null })); +}); + +it("persists separate submissions and retains them across storage refreshes", () => { + const queryClient = new QueryClient(); + const submit = (text: string) => + retainThreadMessage({ + queryClient, + operation: "queue", + draftKey: null, + draft: { ...emptyPromptDraftState(), text }, + request: { + id: "thread-1", + input: [{ type: "text", text, mentions: [] }], + }, + }); + submit("first draft"); + submit("second draft"); + const before = getPendingThreadMessages(); + expect(before).toHaveLength(2); + expect(before[0]?.request.clientSubmissionId).not.toBe( + before[1]?.request.clientSubmissionId, + ); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + expect(getPendingThreadMessages()).toEqual(before); + removePendingThreadMessage(before[0]!.row.id); + expect(getPendingThreadMessages().map((entry) => entry.row.content)).toEqual([ + before[1]!.row.content, + ]); +}); + +it("reports storage failure before a caller can clear its composer", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Full", "QuotaExceededError"); + }); + expect(() => + retainThreadMessage({ + queryClient: new QueryClient(), + operation: "queue", + draftKey: null, + draft: { ...emptyPromptDraftState(), text: "keep me" }, + request: { + id: "thread-1", + input: [{ type: "text", text: "keep me", mentions: [] }], + }, + }), + ).toThrow("Full"); + expect(getPendingThreadMessages()).toHaveLength(0); +}); diff --git a/apps/app/src/lib/pending-thread-messages.ts b/apps/app/src/lib/pending-thread-messages.ts new file mode 100644 index 00000000000..1051f771194 --- /dev/null +++ b/apps/app/src/lib/pending-thread-messages.ts @@ -0,0 +1,127 @@ +import { useSyncExternalStore } from "react"; +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { threadQueuedMessageSchema } from "@bb/domain"; +import { createQueuedMessageRequestSchema } from "@bb/server-contract"; +import { + parsePromptDraftStorage, + serializePromptDraftStorage, + type PromptDraftState, +} from "@bb/client-core"; +import { clearStoredPromptDraftIfCurrentMatches } from "@/hooks/usePromptDraftStorage"; +import { buildOptimisticQueuedMessage } from "@/hooks/cache-owners/thread-runtime-cache-owner"; +import type { QueryClient } from "@tanstack/react-query"; + +const prefix = "bb.pending-thread-message.v1."; +const entrySchema = z.object({ + operation: z.enum(["send", "queue"]), + request: createQueuedMessageRequestSchema.extend({ + id: z.string(), + clientSubmissionId: z.string(), + }), + row: threadQueuedMessageSchema, + draftKey: z.string().nullable(), + draft: z.string().nullable(), + error: z.string().nullable(), +}); +export type PendingThreadMessage = z.infer; +let entries: PendingThreadMessage[] = []; +let initialized = false; +const listeners = new Set<() => void>(); + +function refresh(): void { + const next: PendingThreadMessage[] = []; + let keys: string[]; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + for (const key of keys) { + if (!key?.startsWith(prefix)) continue; + try { + const parsed = entrySchema.safeParse( + JSON.parse(localStorage.getItem(key) ?? "null"), + ); + if (parsed.success) next.push(parsed.data); + } catch {} + } + entries = next.sort( + (a, b) => + a.row.createdAt - b.row.createdAt || a.row.id.localeCompare(b.row.id), + ); + for (const listener of listeners) listener(); +} + +function initialize(): void { + if (initialized || typeof window === "undefined") return; + initialized = true; + refresh(); + for (const entry of entries) { + if (entry.draftKey) + clearStoredPromptDraftIfCurrentMatches( + entry.draftKey, + parsePromptDraftStorage(entry.draft), + ); + } + window.addEventListener("storage", (event) => { + if (event.key === null || event.key.startsWith(prefix)) refresh(); + }); +} + +function subscribe(listener: () => void): () => void { + initialize(); + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getPendingThreadMessages(): readonly PendingThreadMessage[] { + initialize(); + return entries; +} + +export function usePendingThreadMessages(): readonly PendingThreadMessage[] { + return useSyncExternalStore( + subscribe, + getPendingThreadMessages, + getPendingThreadMessages, + ); +} + +export function savePendingThreadMessage(entry: PendingThreadMessage): void { + localStorage.setItem( + prefix + entry.row.id, + JSON.stringify(entrySchema.parse(entry)), + ); + refresh(); +} + +export function removePendingThreadMessage(id: string): void { + localStorage.removeItem(prefix + id); + refresh(); +} + +export function retainThreadMessage(args: { + queryClient: QueryClient; + operation: PendingThreadMessage["operation"]; + request: z.infer & { id: string }; + draft: PromptDraftState; + draftKey: string | null; +}): void { + initialize(); + const request = { ...args.request, clientSubmissionId: nanoid() }; + const row = buildOptimisticQueuedMessage({ + queryClient: args.queryClient, + request, + createdAt: Date.now(), + }); + row.id = `qmsg_${request.id}_${request.clientSubmissionId}`; + savePendingThreadMessage({ + operation: args.operation, + request, + row, + draftKey: args.draftKey, + draft: serializePromptDraftStorage(args.draft), + error: null, + }); +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 100cac9d6f7..60dc292fa4e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1,4 +1,5 @@ import { ThreadMachineStatus } from "@/components/promptbox/banner/ThreadMachineStatus"; +import { useRetainThreadMessage } from "@/hooks/useRetainThreadMessage"; import { useCallback, useEffect, @@ -428,6 +429,7 @@ export function ThreadDetailPromptArea({ thread, }: ThreadDetailPromptAreaProps) { const navigate = useNavigate(); + const pendingMessages = useRetainThreadMessage(); const defaultExecutionOptionsQuery = useThreadDefaultExecutionOptions( thread.id, { @@ -913,6 +915,11 @@ export function ThreadDetailPromptArea({ clearThreadGoal.mutate(thread.id); }, [clearThreadGoal, thread.id]); const submitMode = useMemo(() => { + if ( + !pendingMessages.connected && + !shouldQueueFollowUpMessage(runtimeDisplayStatus) + ) + return { kind: "blocked", reason: "unavailable" }; if (isHandoffSelection && !isStopRequested) { if (effectiveSelectedModel.length > 0) { return { kind: "ready" }; @@ -931,6 +938,7 @@ export function ThreadDetailPromptArea({ runtimeDisplayStatus, }); }, [ + pendingMessages.connected, effectiveSelectedModel, handleStopThread, hasPendingInteraction, @@ -1187,10 +1195,28 @@ export function ThreadDetailPromptArea({ return; } - if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); - setBottomAttachmentError(null); - try { + if (!isQueuingMessage && !pendingMessages.connected) return; + const retainedRequest = buildCreateQueuedFollowUpRequest({ + threadId: thread.id, + input: submittedInput, + execution: followUpExecutionSelection, + }); + if ( + retainedRequest && + pendingMessages.retain({ + request: retainedRequest, + operation: isQueuingMessage ? "queue" : "send", + draft: submittedDraft, + draftKey: promptDraft.storageKey, + }) + ) { + promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); + return; + } + if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); if (isQueuingMessage) { const request = buildCreateQueuedFollowUpRequest({ threadId: thread.id, @@ -1222,6 +1248,7 @@ export function ThreadDetailPromptArea({ }); } }, [ + pendingMessages, createHandoffThread, createQueuedMessage, currentPromptDraft, diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index f7a4aff90b0..37d35788564 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -69,6 +69,7 @@ interface ThreadDeleteCommandOptions { } interface ThreadTellCommandOptions { + submissionId?: string; json?: boolean; messageFile?: string; model?: string; @@ -106,6 +107,7 @@ interface ThreadEditMessageCommandOptions { type ThreadTellDeliveryMode = "auto" | "queue" | "steer"; interface PostThreadMessageArgs { + submissionId?: string; getUrl: () => string; threadId: string; message: string; @@ -457,6 +459,10 @@ export function registerActionsCommands( "Message mode: steer (default), queue, or auto (steer a live turn, else start one)", ) .option("--send-at ", SEND_AT_HELP) + .option( + "--submission-id ", + "Reuse this ID for safe retries of an ordinary --mode queue message", + ) .option("--plan", PLAN_HELP) .option( "--file ", @@ -484,6 +490,7 @@ export function registerActionsCommands( inlineLabel: "", }); const response = await postThreadMessage({ + submissionId: opts.submissionId, getUrl, threadId: id, message, @@ -604,6 +611,9 @@ async function postThreadMessage( sdk, }); const response = await sdk.threads.send({ + ...(args.submissionId === undefined + ? {} + : { clientSubmissionId: args.submissionId }), threadId: args.threadId, input, mode: diff --git a/apps/cli/src/commands/thread/organization.ts b/apps/cli/src/commands/thread/organization.ts index f5e53141b51..f57a4b86c72 100644 --- a/apps/cli/src/commands/thread/organization.ts +++ b/apps/cli/src/commands/thread/organization.ts @@ -49,6 +49,7 @@ interface QueueListOptions extends JsonOptions { } interface QueueCreateOptions extends JsonOptions { + submissionId?: string; messageFile?: string; model?: string; } @@ -342,6 +343,10 @@ export function registerOrganizationCommands( queue .command("create [message]") .description("Create a queued text message") + .option( + "--submission-id ", + "Reuse this ID to avoid duplicates when retrying", + ) .option("--model ", "Model override for the queued message") .option( "--message-file ", @@ -364,6 +369,9 @@ export function registerOrganizationCommands( const result = await createCliBbSdk( getUrl(), ).threads.queuedMessages.create({ + ...(opts.submissionId === undefined + ? {} + : { clientSubmissionId: opts.submissionId }), threadId, input: [{ type: "text", text: message, mentions: [] }], ...(opts.model ? { model: opts.model } : {}), diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 9953591c12f..c760065238d 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -208,6 +208,7 @@ export function registerSystemRoutes( customThemes: listCustomThemeNames(themeRoot), pluginThemes: pluginService.listThemes(), featureFlags: deps.config.featureFlags, + messageSubmissionKeys: true, hostDaemonPort: deps.config.hostDaemonPort, localHelperPorts, serverUrl, diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index e2fa2fb4689..1b6dba012de 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; import { claimNextQueuedThreadMessageGroup, claimQueuedThreadMessageGroup, @@ -12,6 +14,7 @@ import { isThreadQueueAutoSendPaused, releaseQueuedMessageClaim, releaseStaleQueuedMessageClaims, + threadSubmissionReceipts, type DbQueryConnection, type QueuedThreadMessageGroupClaimPolicy, type QueuedThreadMessageGroupEligibility, @@ -19,6 +22,7 @@ import { import { flattenPromptInputGroups, queuedMessageSystemNoticeSchema, + threadQueuedMessageSchema, } from "@bb/domain"; import type { PromptInput, @@ -185,6 +189,7 @@ async function requireReadyQueuedMessageEnvironment( export interface CreateQueuedMessageForThreadArgs { payload: CreateQueuedMessageRequest; thread: Thread; + startWhenIdle?: boolean; } function admitQueuedMessage( @@ -217,6 +222,35 @@ export async function createQueuedMessageForThread( args: CreateQueuedMessageForThreadArgs, ): Promise { const { payload, thread } = args; + const submissionId = payload.clientSubmissionId; + const fingerprint = createHash("sha256") + .update( + JSON.stringify({ payload, startWhenIdle: args.startWhenIdle === true }), + ) + .digest("hex"); + const readReceipt = (db: DbQueryConnection): ThreadQueuedMessage | null => { + if (submissionId === undefined) return null; + const receipt = db + .select() + .from(threadSubmissionReceipts) + .where( + and( + eq(threadSubmissionReceipts.threadId, thread.id), + eq(threadSubmissionReceipts.submissionId, submissionId), + ), + ) + .get(); + if (!receipt) return null; + if (receipt.fingerprint !== fingerprint) + throw new ApiError( + 409, + "client_submission_conflict", + "Submission ID is already used for another message", + ); + return threadQueuedMessageSchema.parse(JSON.parse(receipt.queuedMessage)); + }; + const accepted = readReceipt(deps.db); + if (accepted) return accepted; ensureThreadQueueIsWritable(thread); await validatePromptAttachmentReferences({ db: deps.db, @@ -231,15 +265,26 @@ export async function createQueuedMessageForThread( senderThreadId: payload.senderThreadId, targetThread: thread, }); - const { currentThread, hasProviderSession, queuedMessage } = + const { currentThread, hasProviderSession, queuedMessage, replayed } = deps.db.transaction( (tx) => { + const accepted = readReceipt(tx); + if (accepted) + return { + currentThread: thread, + hasProviderSession: false, + queuedMessage: accepted, + replayed: true, + }; const currentThread = getThread(tx, thread.id); if (!currentThread) { throw new ApiError(404, "thread_not_found", "Thread not found"); } const { hasProviderSession } = admitQueuedMessage(tx, currentThread); const queuedMessage = createQueuedThreadMessageInTransaction(tx, { + ...(submissionId === undefined + ? {} + : { id: `qmsg_${thread.id}_${submissionId}` }), threadId: thread.id, content: payload.input, senderThreadId, @@ -257,17 +302,42 @@ export async function createQueuedMessageForThread( // and runs when the stop lands rather than joining the rows the // manual-stop pause holds back. waitingOn: - currentThread.status === "stopping" - ? { kind: "stopping" } - : { kind: "thread-busy" }, + args.startWhenIdle && + (currentThread.status === "idle" || + currentThread.status === "error") + ? null + : currentThread.status === "stopping" + ? { kind: "stopping" } + : { kind: "thread-busy" }, sendAt: null, payload: { kind: "inline" }, systemNotice: null, }); - return { currentThread, hasProviderSession, queuedMessage }; + const result = { + ...toThreadQueuedMessage(queuedMessage), + ...(submissionId === undefined + ? {} + : { clientSubmissionId: submissionId }), + }; + if (submissionId !== undefined) + tx.insert(threadSubmissionReceipts) + .values({ + threadId: thread.id, + submissionId, + fingerprint, + queuedMessage: JSON.stringify(result), + }) + .run(); + return { + currentThread, + hasProviderSession, + queuedMessage: result, + replayed: false, + }; }, { behavior: "immediate" }, ); + if (replayed) return queuedMessage; deps.hub.notifyThread(thread.id, ["queue-changed"]); if (senderThreadId === null && payload.input.length > 0) { captureUserMessageSentTelemetry(deps, { @@ -276,13 +346,16 @@ export async function createQueuedMessageForThread( providerId: thread.providerId, }); } - if (currentThread.status === "idle" && hasProviderSession) { + if ( + (currentThread.status === "idle" && hasProviderSession) || + args.startWhenIdle + ) { requestQueuedMessageDispatch(deps, { kind: "thread-ready", threadId: thread.id, }); } - return toThreadQueuedMessage(queuedMessage); + return queuedMessage; } function isQueuedMessageAutoSendCandidate( diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 132257c3c4f..f519d5f911a 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -7,6 +7,8 @@ import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; import { attemptDispatch } from "./dispatch-attempt.js"; import { requireThreadCommandEnvironment } from "./thread-command-environment.js"; import { sendThreadMessage } from "./thread-send.js"; +import { createQueuedMessageForThread } from "./queued-messages.js"; +import { ApiError } from "../../errors.js"; interface AcceptThreadSendRequestArgs { payload: SendMessageRequest; @@ -17,6 +19,38 @@ export async function acceptThreadSendRequest( deps: LoggedPendingInteractionWorkSessionDeps, args: AcceptThreadSendRequestArgs, ): Promise { + if (args.payload.clientSubmissionId !== undefined) { + const { mode, pluginSubmission, sendAt, ...payload } = args.payload; + if ( + (mode !== "start" && mode !== "queue-if-active") || + pluginSubmission !== undefined || + sendAt !== undefined || + payload.input.some( + (block) => + block.type === "text" && + block.mentions.some( + (mention) => + mention.resource.kind === "command" && + mention.resource.source === "command", + ), + ) + ) { + throw new ApiError( + 400, + "client_submission_unsupported", + "Submission keys require an ordinary, unscheduled message", + ); + } + return { + ok: true, + delivery: "queued", + queuedMessage: await createQueuedMessageForThread(deps, { + thread: args.thread, + payload, + startWhenIdle: true, + }), + }; + } if (isStandaloneBuiltinClearCommand(args.payload.input)) { const environment = await requireThreadCommandEnvironment(deps, { thread: args.thread, diff --git a/apps/server/test/threads/thread-send-dispatch.test.ts b/apps/server/test/threads/thread-send-dispatch.test.ts index 87246e4a001..06210a354cf 100644 --- a/apps/server/test/threads/thread-send-dispatch.test.ts +++ b/apps/server/test/threads/thread-send-dispatch.test.ts @@ -7,6 +7,7 @@ import { markThreadDeleted, setQueuedThreadMessageFailureReason, setQueuedThreadMessageGroupBoundary, + deleteQueuedThreadMessage, } from "@bb/db"; import type { EnvironmentRow } from "@bb/db"; import { @@ -1355,6 +1356,41 @@ describe("service tier execution lifecycle", () => { }, ); + it("accepts concurrent retries once and remembers acceptance after queue consumption", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedProviderThreadFixture({ + harness, + value: 181, + status: "active", + serviceTier: "default", + }); + const args = { + thread, + payload: { + input: textInput("keep this message"), + clientSubmissionId: "same-submission", + }, + }; + const [first, retry] = await Promise.all([ + createQueuedMessageForThread(harness.deps, args), + createQueuedMessageForThread(harness.deps, args), + ]); + expect(retry).toEqual(first); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(1); + await expect( + createQueuedMessageForThread(harness.deps, { + ...args, + payload: { ...args.payload, input: textInput("different message") }, + }), + ).rejects.toThrow("already used"); + deleteQueuedThreadMessage(harness.db, harness.deps.hub, first.id); + expect(await createQueuedMessageForThread(harness.deps, args)).toEqual( + first, + ); + expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); + }); + }); + it("keeps queued choices separate until dispatch and preserves the next row", async () => { await withTestHarness(async (harness) => { const { thread } = seedProviderThreadFixture({ diff --git a/packages/db/drizzle/0127_parched_roxanne_simpson.sql b/packages/db/drizzle/0127_parched_roxanne_simpson.sql new file mode 100644 index 00000000000..82d83db4270 --- /dev/null +++ b/packages/db/drizzle/0127_parched_roxanne_simpson.sql @@ -0,0 +1,8 @@ +CREATE TABLE `thread_submission_receipts` ( + `thread_id` text NOT NULL, + `submission_id` text NOT NULL, + `fingerprint` text NOT NULL, + `queued_message` text NOT NULL, + PRIMARY KEY(`thread_id`, `submission_id`), + FOREIGN KEY (`thread_id`) REFERENCES `threads`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/packages/db/drizzle/meta/0127_snapshot.json b/packages/db/drizzle/meta/0127_snapshot.json new file mode 100644 index 00000000000..f8a43bceb1a --- /dev/null +++ b/packages/db/drizzle/meta/0127_snapshot.json @@ -0,0 +1,5061 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "85aefa10-9eaa-4dfb-8cba-1b97f3efa0ad", + "prevId": "48979c46-bc2b-411d-a6cc-18dfcb7cd457", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryption_version": { + "name": "encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_variables_global_name": { + "name": "environment_variables_global_name", + "columns": [ + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NULL" + }, + "environment_variables_project_name": { + "name": "environment_variables_project_name", + "columns": [ + "project_id", + "name" + ], + "isUnique": true, + "where": "\"environment_variables\".\"project_id\" IS NOT NULL" + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_provider_identity_idx": { + "name": "events_provider_identity_idx", + "columns": [ + "provider_thread_id", + "created_at" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'thread/identity'" + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "isUnique": true, + "where": "\"hosts\".\"destroyed_at\" is null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_backfills": { + "name": "project_attachment_backfills", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_cursor": { + "name": "thread_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_cursor": { + "name": "input_cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_id": { + "name": "input_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_sequence": { + "name": "input_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_attachment_backfills_project_id_projects_id_fk": { + "name": "project_attachment_backfills_project_id_projects_id_fk", + "tableFrom": "project_attachment_backfills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachment_threads": { + "name": "project_attachment_threads", + "columns": { + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_attachment_threads_thread_idx": { + "name": "project_attachment_threads_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_attachment_threads_attachment_id_project_attachments_id_fk": { + "name": "project_attachment_threads_attachment_id_project_attachments_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "project_attachments", + "columnsFrom": [ + "attachment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_attachment_threads_thread_id_threads_id_fk": { + "name": "project_attachment_threads_thread_id_threads_id_fk", + "tableFrom": "project_attachment_threads", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_attachment_threads_attachment_id_thread_id_pk": { + "columns": [ + "attachment_id", + "thread_id" + ], + "name": "project_attachment_threads_attachment_id_thread_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_attachments": { + "name": "project_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_path": { + "name": "stored_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ready_at": { + "name": "ready_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletion_claimed_at": { + "name": "deletion_claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_attachments_project_path_idx": { + "name": "project_attachments_project_path_idx", + "columns": [ + "project_id", + "stored_path" + ], + "isUnique": true + }, + "project_attachments_project_created_idx": { + "name": "project_attachments_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + }, + "project_attachments_deletion_idx": { + "name": "project_attachments_deletion_idx", + "columns": [ + "project_id", + "deletion_claimed_at", + "id" + ], + "isUnique": false, + "where": "\"project_attachments\".\"deletion_claimed_at\" IS NOT NULL" + } + }, + "foreignKeys": { + "project_attachments_project_id_projects_id_fk": { + "name": "project_attachments_project_id_projects_id_fk", + "tableFrom": "project_attachments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_attachments_size_check": { + "name": "project_attachments_size_check", + "value": "\"project_attachments\".\"size_bytes\" >= 0" + } + } + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_model_catalogs": { + "name": "provider_model_catalogs", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_only_models_json": { + "name": "selected_only_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "provider_model_catalogs_host_id_hosts_id_fk": { + "name": "provider_model_catalogs_host_id_hosts_id_fk", + "tableFrom": "provider_model_catalogs", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_model_catalogs_host_id_provider_id_scope_key_pk": { + "columns": [ + "host_id", + "provider_id", + "scope_key" + ], + "name": "provider_model_catalogs_host_id_provider_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_initiator": { + "name": "requested_by_initiator", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_by_thread_id": { + "name": "requested_by_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_plugin_metadata": { + "name": "thread_plugin_metadata", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_plugin_metadata_thread_id_threads_id_fk": { + "name": "thread_plugin_metadata_thread_id_threads_id_fk", + "tableFrom": "thread_plugin_metadata", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_plugin_metadata_thread_id_plugin_id_pk": { + "columns": [ + "thread_id", + "plugin_id" + ], + "name": "thread_plugin_metadata_thread_id_plugin_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_pruning_cursors": { + "name": "thread_pruning_cursors", + "columns": { + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_thread_id": { + "name": "last_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "current_thread_id": { + "name": "current_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step": { + "name": "step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upper_sequence": { + "name": "upper_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cycle": { + "name": "cycle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_root_sequence": { + "name": "latest_root_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latest_context_sequence": { + "name": "latest_context_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_event_id": { + "name": "probe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_phase": { + "name": "probe_phase", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_sequence": { + "name": "probe_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "probe_witness_id": { + "name": "probe_witness_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_pruning_cursors_thread_idx": { + "name": "thread_pruning_cursors_thread_idx", + "columns": [ + "thread_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_pruning_cursors_thread_id_threads_id_fk": { + "name": "thread_pruning_cursors_thread_id_threads_id_fk", + "tableFrom": "thread_pruning_cursors", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_pruning_cursors_policy_scope_pk": { + "columns": [ + "policy", + "scope" + ], + "name": "thread_pruning_cursors_policy_scope_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "thread_pruning_cursors_scope_check": { + "name": "thread_pruning_cursors_scope_check", + "value": "\"thread_pruning_cursors\".\"scope\" = coalesce(\"thread_pruning_cursors\".\"thread_id\", '')" + } + } + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_submission_receipts": { + "name": "thread_submission_receipts", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queued_message": { + "name": "queued_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_submission_receipts_thread_id_threads_id_fk": { + "name": "thread_submission_receipts_thread_id_threads_id_fk", + "tableFrom": "thread_submission_receipts", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "thread_submission_receipts_thread_id_submission_id_pk": { + "columns": [ + "thread_id", + "submission_id" + ], + "name": "thread_submission_receipts_thread_id_submission_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle_owner_thread_id": { + "name": "lifecycle_owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_deleted_at": { + "name": "storage_deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_id_idx": { + "name": "threads_project_id_idx", + "columns": [ + "project_id", + "id" + ], + "isUnique": false + }, + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_lifecycle_owner_idx": { + "name": "threads_lifecycle_owner_idx", + "columns": [ + "lifecycle_owner_thread_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_lifecycle_owner_thread_id_threads_id_fk": { + "name": "threads_lifecycle_owner_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "lifecycle_owner_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6a30ce673b3..e8ba01e4586 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -890,6 +890,13 @@ "when": 1789607725218, "tag": "0126_overconfident_vin_gonzales", "breakpoints": true + }, + { + "idx": 127, + "version": "6", + "when": 1789843084867, + "tag": "0127_parched_roxanne_simpson", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 79a029c6a60..590c0efc978 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -54,6 +54,7 @@ import { createOrderKeyAfter, createOrderKeyBetween } from "./order-keys.js"; import { queryInSqliteVariableBatches } from "./events.js"; export interface CreateQueuedThreadMessageInput { + id?: string; threadId: string; content: PromptInput[]; senderThreadId?: string | null; @@ -608,7 +609,7 @@ export function createQueuedThreadMessageInTransaction( input.threadId, projectAttachmentPaths(input.content), ); - const id = createQueuedThreadMessageId(); + const id = input.id ?? createQueuedThreadMessageId(); const lastQueuedMessage = getLastQueuedThreadMessage(tx, input.threadId); const sortKey = lastQueuedMessage ? createOrderKeyAfter({ previousKey: lastQueuedMessage.sortKey }) diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 68c9b152330..64a664bbf32 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -756,6 +756,19 @@ export const threadDynamicContextFileStates = sqliteTable( ], ); +export const threadSubmissionReceipts = sqliteTable( + "thread_submission_receipts", + { + threadId: text("thread_id") + .notNull() + .references(() => threads.id, { onDelete: "cascade" }), + submissionId: text("submission_id").notNull(), + fingerprint: text("fingerprint").notNull(), + queuedMessage: text("queued_message").notNull(), + }, + (table) => [primaryKey({ columns: [table.threadId, table.submissionId] })], +); + export const events = sqliteTable( "events", { diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 405edfee51a..c93228c50b7 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -336,6 +336,7 @@ export const threadPullRequestSchema = z export type ThreadPullRequest = z.infer; export const threadQueuedMessageSchema = z.object({ + clientSubmissionId: z.string().optional(), id: z.string(), origin: threadCreateOriginSchema.nullable(), originPluginId: z.string().nullable(), diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 292420f4382..34dd9b9d4dc 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -264,6 +264,7 @@ export interface ThreadDeleteArgs extends DeleteThreadRequest { } export interface ThreadSendArgs extends SendMessageRequest { + signal?: AbortSignal; threadId: string; } @@ -312,6 +313,7 @@ export interface ThreadQueuedMessageArgs { } export interface ThreadQueuedMessageCreateArgs extends CreateQueuedMessageRequest { + signal?: AbortSignal; threadId: string; } @@ -673,6 +675,7 @@ function updateJson(args: ThreadUpdateArgs): UpdateThreadRequest { function sendJson(args: ThreadSendArgs): SendMessageRequest { return { + clientSubmissionId: args.clientSubmissionId, input: args.input, mode: args.mode, model: args.model, @@ -965,12 +968,15 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }; const queuedMessages: ThreadQueuedMessagesArea = { async create(input) { - const { threadId, ...json } = input; + const { threadId, signal, ...json } = input; return transport.readJson( - transport.api.v1.threads[":id"]["queued-messages"].$post({ - param: { id: threadId }, - json, - }), + transport.api.v1.threads[":id"]["queued-messages"].$post( + { + param: { id: threadId }, + json, + }, + ...signalRequestArgs(signal), + ), ); }, async delete(input) { @@ -1288,10 +1294,13 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { }, async send(input) { return transport.readJson( - transport.api.v1.threads[":id"].send.$post({ - param: { id: input.threadId }, - json: sendJson(input), - }), + transport.api.v1.threads[":id"].send.$post( + { + param: { id: input.threadId }, + json: sendJson(input), + }, + ...signalRequestArgs(input.signal), + ), ); }, async retry(input) { diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 5590e36ae96..24f54be99db 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -178,6 +178,7 @@ export const serverAccessStatusSchema = z.object({ export type ServerAccessStatus = z.infer; export const systemConfigResponseSchema = z.object({ + messageSubmissionKeys: z.boolean().optional(), serverAccess: serverAccessStatusSchema, generalSettings: appSettingsSchema.extend({ showUnhandledProviderEvents: z.boolean().optional(), diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts index 6e3b33f2dd4..10755285091 100644 --- a/packages/server-contract/src/api/threads.ts +++ b/packages/server-contract/src/api/threads.ts @@ -231,6 +231,7 @@ export const forkThreadRequestSchema = z export type ForkThreadRequest = z.infer; const sendMessageRequestFieldsSchema = z.object({ + clientSubmissionId: z.string().min(1).max(100).optional(), input: z.array(promptInputSchema).min(1), model: z.string().optional(), serviceTier: serviceTierSchema.optional(), @@ -361,6 +362,7 @@ export const sendQueuedMessageModeSchema = z.enum(["auto", "steer"]); export type SendQueuedMessageMode = z.infer; export const createQueuedMessageRequestSchema = z.object({ + clientSubmissionId: z.string().min(1).max(100).optional(), input: z.array(promptInputSchema).min(1), model: z.string().optional(), serviceTier: serviceTierSchema.optional(), diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index 0329c745ac1..cbf8d404056 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -349,7 +349,7 @@ Interactions: Queued messages: bb thread queue list [] [--wait-holder plugin:] - bb thread queue create + bb thread queue create [--submission-id ] bb thread queue update [--file ] [--image ] bb thread queue send [--mode auto|steer] bb thread queue reorder [--after ] [--before ] @@ -359,6 +359,11 @@ Queued messages: The `Sender` column identifies agent threads and system notices; user messages leave it blank. The SDK and `--json` include `initiator` and `senderThreadId`. + Reuse --submission-id with the same message when retrying queue create or + thread tell --mode queue after a lost response. The server recognizes the + submission even after dispatch. Keys are scoped to a thread and do not + support steering, commands, or scheduled sends. + A queued message is one that could not dispatch yet. Every one carries a typed reason in its `Waiting on` column: waiting for the current turn to finish, for the workspace, for a pending interaction, for a clock (`Send at`), diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index 88984cd9adf..b208d0e5960 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -1,5 +1,7 @@ # Thread coordination and inspection +For duplicate-safe retries of ordinary messages, use `bb thread tell ID MESSAGE --mode queue --submission-id KEY` or `bb thread queue create ID MESSAGE --submission-id KEY`. Reuse the same key and payload for retries on the same thread, including after a response is lost. Keys remain recognized after the queued message has dispatched; different content with the same key is rejected. Submission keys do not apply to steering, commands, or scheduled sends. + ## Coordinating Work - Use one clear owner per task. From 8207008f289e2c7a1e3d451008b98017a588d922 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:49:09 -0400 Subject: [PATCH 21/29] Integrate pending delivery with existing cache ownership and contract checks --- .../thread/embedded-chat/EmbeddedThreadChat.test.tsx | 3 +++ apps/app/src/lib/PendingThreadMessagesSync.tsx | 6 ++---- .../ThreadDetailPromptArea.keystrokes.test.tsx | 3 +++ .../src/views/thread-detail/ThreadDetailPromptArea.test.tsx | 3 +++ packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- packages/server-contract/test/contract.test.ts | 3 +++ 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index 55b8fdf0e34..8a540447f52 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -302,6 +302,9 @@ vi.mock("@/hooks/queries/system-queries", () => ({ }), })); +vi.mock("@/hooks/useRetainThreadMessage", () => ({ + useRetainThreadMessage: () => ({ connected: true, retain: () => false }), +})); vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ useCreateThreadQueuedMessage: () => ({ mutateAsync: mocks.createQueuedMessageMutateAsync, diff --git a/apps/app/src/lib/PendingThreadMessagesSync.tsx b/apps/app/src/lib/PendingThreadMessagesSync.tsx index b8448da7d82..cd9eb0196a9 100644 --- a/apps/app/src/lib/PendingThreadMessagesSync.tsx +++ b/apps/app/src/lib/PendingThreadMessagesSync.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useServerConnectionState } from "@/hooks/useServerConnectionState"; import { useSystemConfig } from "@/hooks/queries/system-queries"; -import { threadQueuedMessagesQueryKey } from "@/hooks/queries/query-keys"; +import { invalidateThreadQueuedMessageListQuery } from "@/hooks/cache-owners/mutation-cache-effects"; import { BbHttpError, sdk } from "./sdk"; import { wsManager } from "./ws"; import { @@ -53,9 +53,7 @@ function PendingDelivery({ entry }: { entry: PendingThreadMessage }) { ); if (result?.clientSubmissionId !== request.clientSubmissionId) throw new Error("Waiting for server confirmation"); - await queryClient.invalidateQueries({ - queryKey: threadQueuedMessagesQueryKey(id), - }); + invalidateThreadQueuedMessageListQuery({ queryClient, threadId: id }); removePendingThreadMessage(entry.row.id); }, onError: (error) => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx index 5991fb5ece5..e4059ce1a93 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx @@ -216,6 +216,9 @@ vi.mock("@/hooks/mutations/project-mutations", () => ({ }), })); +vi.mock("@/hooks/useRetainThreadMessage", () => ({ + useRetainThreadMessage: () => ({ connected: true, retain: () => false }), +})); vi.mock("@/hooks/mutations/thread-runtime-mutations", () => { const idleMutation = () => ({ isPending: false, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 9d198372f22..59a488eba2a 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -653,6 +653,9 @@ vi.mock("@/hooks/mutations/project-mutations", () => ({ }), })); +vi.mock("@/hooks/useRetainThreadMessage", () => ({ + useRetainThreadMessage: () => ({ connected: true, retain: () => false }), +})); vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ useCancelThreadPlan: () => ({ isPending: false, diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 6bae9ee9f76..e6dc0e7cdde 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.106"; +export const PLUGIN_SDK_VERSION = "0.4.107"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index fbf0990e803..28fd6a30e58 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.106", + "version": "0.4.107", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 602d1dae150..3f3cb8dd5ea 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -395,6 +395,9 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ reason: "Execution input source metadata is omitted by legacy callers; when omitted, supplied execution values are treated as explicit.", fields: [ + "createQueuedMessageRequestSchema.clientSubmissionId", + "sendMessageRequestSchema.clientSubmissionId", + "sendQueuedMessageResponseSchema.queuedMessage.clientSubmissionId", "createQueuedMessageRequestSchema.executionInputSources", "createQueuedMessageRequestSchema.executionInputSources.model", "createQueuedMessageRequestSchema.executionInputSources.permissionMode", From e23b447b1cecaf2b7d373a1dde83840619eef336 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:54:14 -0400 Subject: [PATCH 22/29] Keep pending message plumbing out of the boot bundle and update migration fixtures --- apps/app/src/App.tsx | 11 +++++++-- .../promptbox/banner/QueuedMessagesList.tsx | 6 +++-- .../embedded-chat/EmbeddedThreadChat.tsx | 7 +++++- apps/app/src/hooks/queries/thread-queries.ts | 14 +---------- apps/app/src/lib/pending-thread-messages.ts | 24 +++++++++++++++++-- .../thread-detail/ThreadDetailPromptArea.tsx | 6 ++++- packages/db/test/migrate.test.ts | 6 ++--- 7 files changed, 50 insertions(+), 24 deletions(-) diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 98237a93073..291e32d344b 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -15,7 +15,6 @@ import { RouteNavigationIndicator } from "./components/ui/route-navigation-indic import { AppNavigationUrlHost } from "./lib/url-open-routing"; import { NativeShellReporter } from "./lib/native-shell"; import { UiPreferencesSync } from "@/lib/ui-preferences/UiPreferencesSync"; -import { PendingThreadMessagesSync } from "@/lib/PendingThreadMessagesSync"; import { AppFileExternalNavigationHost } from "./components/plugin/AppFileExternalNavigationHost"; import { useAppTheme } from "./hooks/useAppTheme"; import { useFaviconColorSync } from "./lib/favicon-color-preference"; @@ -70,6 +69,12 @@ import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provi import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; +const PendingThreadMessagesSync = lazy(() => + import("@/lib/PendingThreadMessagesSync").then((module) => ({ + default: module.PendingThreadMessagesSync, + })), +); + const SettingsView = lazy(() => import("./views/SettingsView").then((m) => ({ default: m.SettingsView, @@ -434,7 +439,9 @@ export function App() { - + + + removePendingThreadMessage(queuedMessage.id) : () => onDelete(queuedMessage.id); @@ -1047,7 +1046,10 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ "shrink-0 text-muted-foreground hover:text-destructive max-md:text-destructive", compact ? "size-7" : "size-8", )} - disabled={actionDisabled} + disabled={ + actionDisabled || + (pending !== undefined && pending.error === null) + } onClick={deleteMessage} aria-label={`Delete queued message ${index + 1}`} > diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 879f16a8c28..689eadfb257 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -56,6 +56,7 @@ import { import { useThreadDefaultExecutionOptions } from "@/hooks/queries/thread-default-execution-options-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useRetainThreadMessage } from "@/hooks/useRetainThreadMessage"; +import { usePendingQueuedMessages } from "@/lib/pending-thread-messages"; import { useCreateThreadQueuedMessage, useSendThreadMessage, @@ -263,7 +264,11 @@ function EmbeddedThreadChatWithComposer({ markThreadRead, thread: threadQuery.data, }); - const { data: queuedMessages = [] } = useThreadQueuedMessages(threadId); + const { data: serverQueuedMessages } = useThreadQueuedMessages(threadId); + const queuedMessages = usePendingQueuedMessages( + threadId, + serverQueuedMessages, + ); const executionOptionsQuery = useThreadDefaultExecutionOptions( composer.executionDefaultsThreadId, diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index f35c50a58c9..0922143f951 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1,5 +1,4 @@ import { prependOlderTimelineRows } from "@bb/client-core"; -import { usePendingThreadMessages } from "@/lib/pending-thread-messages"; import { useInfiniteQuery, useQuery, @@ -703,11 +702,10 @@ export function useThreadQueuedMessages( id: string, options?: ThreadQueuedMessagesQueryOptions, ) { - const pending = usePendingThreadMessages(); const enabled = (options?.enabled ?? true) && Boolean(id); useThreadDetailRealtimeSubscription(id, { enabled }); - const query = useQuery({ + return useQuery({ queryKey: threadQueuedMessagesQueryKey(id), queryFn: ({ signal }) => sdk.threads.queuedMessages.list({ @@ -719,16 +717,6 @@ export function useThreadQueuedMessages( refetchOnWindowFocus: true, staleTime: options?.staleTime, }); - const data = useMemo(() => { - const local = pending.filter((entry) => entry.request.id === id); - if (local.length === 0) return query.data; - const localIds = new Set(local.map((entry) => entry.row.id)); - return [ - ...(query.data ?? []).filter((row) => !localIds.has(row.id)), - ...local.map((entry) => entry.row), - ]; - }, [pending, id, query.data]); - return { ...query, data }; } export function useThreadPromptHistory( diff --git a/apps/app/src/lib/pending-thread-messages.ts b/apps/app/src/lib/pending-thread-messages.ts index 1051f771194..2313e905af6 100644 --- a/apps/app/src/lib/pending-thread-messages.ts +++ b/apps/app/src/lib/pending-thread-messages.ts @@ -1,7 +1,10 @@ -import { useSyncExternalStore } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { z } from "zod"; import { nanoid } from "nanoid"; -import { threadQueuedMessageSchema } from "@bb/domain"; +import { + threadQueuedMessageSchema, + type ThreadQueuedMessage, +} from "@bb/domain"; import { createQueuedMessageRequestSchema } from "@bb/server-contract"; import { parsePromptDraftStorage, @@ -88,6 +91,23 @@ export function usePendingThreadMessages(): readonly PendingThreadMessage[] { ); } +const emptyQueue: readonly ThreadQueuedMessage[] = []; +export function usePendingQueuedMessages( + threadId: string, + serverRows: readonly ThreadQueuedMessage[] = emptyQueue, +): readonly ThreadQueuedMessage[] { + const pending = usePendingThreadMessages(); + return useMemo(() => { + const local = pending.filter((entry) => entry.request.id === threadId); + if (local.length === 0) return serverRows; + const ids = new Set(local.map((entry) => entry.row.id)); + return [ + ...serverRows.filter((row) => !ids.has(row.id)), + ...local.map((entry) => entry.row), + ]; + }, [pending, threadId, serverRows]); +} + export function savePendingThreadMessage(entry: PendingThreadMessage): void { localStorage.setItem( prefix + entry.row.id, diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 60dc292fa4e..73197953003 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1,5 +1,6 @@ import { ThreadMachineStatus } from "@/components/promptbox/banner/ThreadMachineStatus"; import { useRetainThreadMessage } from "@/hooks/useRetainThreadMessage"; +import { usePendingQueuedMessages } from "@/lib/pending-thread-messages"; import { useCallback, useEffect, @@ -456,7 +457,10 @@ export function ThreadDetailPromptArea({ const queuedMessagesQuery = useThreadQueuedMessages(thread.id, { enabled: true, }); - const queuedMessages = queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES; + const queuedMessages = usePendingQueuedMessages( + thread.id, + queuedMessagesQuery.data ?? EMPTY_QUEUED_MESSAGES, + ); const queuedMessagesPending = queuedMessagesQuery.data === undefined && queuedMessageCount > 0; const queuedMessagesRef = diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 4da166327e3..cd12b666636 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -781,6 +781,7 @@ function rewindEnvironmentProvisioningMigration(db: DbConnection): void { } function dropQueueReworkSchema(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS thread_submission_receipts"); rewindEnvironmentProvisioningMigration(db); // Indexes first: SQLite refuses to drop a column an existing index names. for (const index of [ @@ -858,6 +859,7 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } function rewindMachineProvidersMigration(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS thread_submission_receipts"); const queuedDispatchOrigin = db.$client .prepare<[], TableInfoRow>("PRAGMA table_info(queued_thread_messages)") .all(); @@ -868,9 +870,7 @@ function rewindMachineProvidersMigration(db: DbConnection): void { "requested_by_thread_id", ]) { if (!queuedDispatchOrigin.some((column) => column.name === name)) continue; - db.$client.exec( - `ALTER TABLE queued_thread_messages DROP COLUMN ${name}`, - ); + db.$client.exec(`ALTER TABLE queued_thread_messages DROP COLUMN ${name}`); } db.$client.exec("DROP TABLE IF EXISTS thread_pruning_cursors"); db.$client.exec("DROP TABLE IF EXISTS project_attachment_threads"); From 9e543239ebe9c194af375644ec8cef20fe7a4c60 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:59:07 -0400 Subject: [PATCH 23/29] Keep draft recovery conservative and avoid clearing newer composer work --- .../embedded-chat/EmbeddedThreadChat.tsx | 2 -- apps/app/src/hooks/usePromptDraftStorage.ts | 8 -------- .../lib/PendingThreadMessagesSync.test.tsx | 3 --- .../src/lib/pending-thread-messages.test.tsx | 5 ----- apps/app/src/lib/pending-thread-messages.ts | 19 ------------------- .../thread-detail/ThreadDetailPromptArea.tsx | 2 -- 6 files changed, 39 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 689eadfb257..288cf07753b 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -517,8 +517,6 @@ function EmbeddedThreadChatWithComposer({ ...executionRequestFields, }, operation: isQueuingMessage ? "queue" : "send", - draft: submittedDraft, - draftKey: promptDraft.storageKey, }) ) { promptDraft.clearIfCurrentMatches(submittedDraft); diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index fafecff3b39..445eb5ecd7e 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -33,14 +33,6 @@ interface PromptDraftWriteOptions { const EMPTY_PROMPT_DRAFT = emptyPromptDraftState(); -export function clearStoredPromptDraftIfCurrentMatches( - storageKey: string, - expectedDraft: PromptDraftState, -): void { - if (arePromptDraftStatesEqual(readPromptDraft(storageKey), expectedDraft)) { - writePromptDraft(storageKey, EMPTY_PROMPT_DRAFT, { persist: "immediate" }); - } -} const promptDraftCache = new Map(); const promptDraftSubscribers = new Map>(); const pendingPromptDraftStorageKeys = new Set(); diff --git a/apps/app/src/lib/PendingThreadMessagesSync.test.tsx b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx index ea25ba6640f..99c6830ca9a 100644 --- a/apps/app/src/lib/PendingThreadMessagesSync.test.tsx +++ b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; -import { emptyPromptDraftState } from "@bb/client-core"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { PendingThreadMessagesSync } from "./PendingThreadMessagesSync"; import { @@ -44,8 +43,6 @@ it("retains an uncertain request and retries with the same submission key until retainThreadMessage({ queryClient, operation: "queue", - draftKey: null, - draft: { ...emptyPromptDraftState(), text: "saved locally" }, request: { id: "thread-1", input: [{ type: "text", text: "saved locally", mentions: [] }], diff --git a/apps/app/src/lib/pending-thread-messages.test.tsx b/apps/app/src/lib/pending-thread-messages.test.tsx index 6796d7277a6..95b866e93a3 100644 --- a/apps/app/src/lib/pending-thread-messages.test.tsx +++ b/apps/app/src/lib/pending-thread-messages.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; import { QueryClient } from "@tanstack/react-query"; -import { emptyPromptDraftState } from "@bb/client-core"; import { getPendingThreadMessages, retainThreadMessage, @@ -20,8 +19,6 @@ it("persists separate submissions and retains them across storage refreshes", () retainThreadMessage({ queryClient, operation: "queue", - draftKey: null, - draft: { ...emptyPromptDraftState(), text }, request: { id: "thread-1", input: [{ type: "text", text, mentions: [] }], @@ -50,8 +47,6 @@ it("reports storage failure before a caller can clear its composer", () => { retainThreadMessage({ queryClient: new QueryClient(), operation: "queue", - draftKey: null, - draft: { ...emptyPromptDraftState(), text: "keep me" }, request: { id: "thread-1", input: [{ type: "text", text: "keep me", mentions: [] }], diff --git a/apps/app/src/lib/pending-thread-messages.ts b/apps/app/src/lib/pending-thread-messages.ts index 2313e905af6..d8baab1c338 100644 --- a/apps/app/src/lib/pending-thread-messages.ts +++ b/apps/app/src/lib/pending-thread-messages.ts @@ -6,12 +6,6 @@ import { type ThreadQueuedMessage, } from "@bb/domain"; import { createQueuedMessageRequestSchema } from "@bb/server-contract"; -import { - parsePromptDraftStorage, - serializePromptDraftStorage, - type PromptDraftState, -} from "@bb/client-core"; -import { clearStoredPromptDraftIfCurrentMatches } from "@/hooks/usePromptDraftStorage"; import { buildOptimisticQueuedMessage } from "@/hooks/cache-owners/thread-runtime-cache-owner"; import type { QueryClient } from "@tanstack/react-query"; @@ -23,8 +17,6 @@ const entrySchema = z.object({ clientSubmissionId: z.string(), }), row: threadQueuedMessageSchema, - draftKey: z.string().nullable(), - draft: z.string().nullable(), error: z.string().nullable(), }); export type PendingThreadMessage = z.infer; @@ -60,13 +52,6 @@ function initialize(): void { if (initialized || typeof window === "undefined") return; initialized = true; refresh(); - for (const entry of entries) { - if (entry.draftKey) - clearStoredPromptDraftIfCurrentMatches( - entry.draftKey, - parsePromptDraftStorage(entry.draft), - ); - } window.addEventListener("storage", (event) => { if (event.key === null || event.key.startsWith(prefix)) refresh(); }); @@ -125,8 +110,6 @@ export function retainThreadMessage(args: { queryClient: QueryClient; operation: PendingThreadMessage["operation"]; request: z.infer & { id: string }; - draft: PromptDraftState; - draftKey: string | null; }): void { initialize(); const request = { ...args.request, clientSubmissionId: nanoid() }; @@ -140,8 +123,6 @@ export function retainThreadMessage(args: { operation: args.operation, request, row, - draftKey: args.draftKey, - draft: serializePromptDraftStorage(args.draft), error: null, }); } diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 73197953003..dedef534b1a 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1211,8 +1211,6 @@ export function ThreadDetailPromptArea({ pendingMessages.retain({ request: retainedRequest, operation: isQueuingMessage ? "queue" : "send", - draft: submittedDraft, - draftKey: promptDraft.storageKey, }) ) { promptDraft.clearIfCurrentMatches(submittedDraft); From 485a8307c197964f316a154075bf415e5123ba30 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 15:48:01 -0400 Subject: [PATCH 24/29] Mark generated schema snapshots and remove stray draft-store diff --- .gitattributes | 1 + apps/app/src/hooks/usePromptDraftStorage.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 6313b56c578..673d126db59 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +packages/db/drizzle/meta/*_snapshot.json linguist-generated=true diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts index 445eb5ecd7e..4662f8e1b9f 100644 --- a/apps/app/src/hooks/usePromptDraftStorage.ts +++ b/apps/app/src/hooks/usePromptDraftStorage.ts @@ -32,7 +32,6 @@ interface PromptDraftWriteOptions { } const EMPTY_PROMPT_DRAFT = emptyPromptDraftState(); - const promptDraftCache = new Map(); const promptDraftSubscribers = new Map>(); const pendingPromptDraftStorageKeys = new Set(); From 43538a60a1a544b3dd2ae5c0e4cbb3fa7a602839 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 09:04:12 -0700 Subject: [PATCH 25/29] Retain steering messages through the shared submission recovery path --- .../embedded-chat/EmbeddedThreadChat.tsx | 24 ++++++ .../lib/PendingThreadMessagesSync.test.tsx | 77 +++++++++++-------- .../app/src/lib/PendingThreadMessagesSync.tsx | 5 +- apps/app/src/lib/pending-thread-messages.ts | 2 +- .../thread-detail/ThreadDetailPromptArea.tsx | 20 +++++ .../src/services/threads/queued-messages.ts | 20 +++-- .../services/threads/thread-send-request.ts | 5 +- .../test/threads/thread-send-dispatch.test.ts | 62 +++++++++++++++ .../src/templates/bb-guide-threads.md | 7 +- .../bb-cli/references/thread-operation.md | 2 +- 10 files changed, 179 insertions(+), 45 deletions(-) diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 288cf07753b..e38f8ae5e02 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -609,6 +609,29 @@ function EmbeddedThreadChatWithComposer({ return; } + try { + if ( + pendingMessages.retain({ + request: { + id: threadId, + input: submittedInput, + ...executionRequestFields, + }, + operation: "steer", + }) + ) { + promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); + return; + } + } catch (error) { + showMutationErrorToast({ + error, + fallbackMessage: "Could not save message on this device", + lifecycleOperation: "send_message", + }); + return; + } promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); setIsTurnSubmitting(true); @@ -643,6 +666,7 @@ function EmbeddedThreadChatWithComposer({ currentPromptDraft, currentPromptDraftInput, executionRequestFields, + pendingMessages, promptDraft, queuedMessages, sendQueuedMessageById, diff --git a/apps/app/src/lib/PendingThreadMessagesSync.test.tsx b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx index 99c6830ca9a..c45b41e4efd 100644 --- a/apps/app/src/lib/PendingThreadMessagesSync.test.tsx +++ b/apps/app/src/lib/PendingThreadMessagesSync.test.tsx @@ -20,7 +20,7 @@ vi.mock("./ws", () => ({ })); vi.mock("./sdk", async (importOriginal) => ({ ...(await importOriginal()), - sdk: { threads: { queuedMessages: { create: vi.fn() } } }, + sdk: { threads: { send: vi.fn(), queuedMessages: { create: vi.fn() } } }, })); afterEach(() => { @@ -30,34 +30,49 @@ afterEach(() => { vi.clearAllMocks(); }); -it("retains an uncertain request and retries with the same submission key until confirmed", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - vi.mocked(sdk.threads.queuedMessages.create) - .mockRejectedValueOnce(new TypeError("Response lost")) - .mockImplementationOnce(async (request) => ({ - ...getPendingThreadMessages()[0]!.row, - clientSubmissionId: request.clientSubmissionId, +it.each(["queue", "send", "steer"] as const)( + "retains an uncertain %s and retries with the same submission key until confirmed", + async (operation) => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + vi.mocked(sdk.threads.queuedMessages.create) + .mockRejectedValueOnce(new TypeError("Response lost")) + .mockImplementationOnce(async (request) => ({ + ...getPendingThreadMessages()[0]!.row, + clientSubmissionId: request.clientSubmissionId, + })); + vi.mocked(sdk.threads.send).mockImplementation(async (request) => ({ + ok: true, + delivery: "queued", + queuedMessage: await sdk.threads.queuedMessages.create(request), })); - render(, { wrapper }); - act(() => - retainThreadMessage({ - queryClient, - operation: "queue", - request: { - id: "thread-1", - input: [{ type: "text", text: "saved locally", mentions: [] }], - }, - }), - ); - await waitFor(() => - expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), - ); - expect(getPendingThreadMessages()).toHaveLength(1); - await waitFor(() => expect(getPendingThreadMessages()).toHaveLength(0), { - timeout: 3000, - }); - const calls = vi.mocked(sdk.threads.queuedMessages.create).mock.calls; - expect(calls).toHaveLength(2); - expect(calls[1]![0].clientSubmissionId).toBe(calls[0]![0].clientSubmissionId); - queryClient.clear(); -}); + render(, { wrapper }); + act(() => + retainThreadMessage({ + queryClient, + operation, + request: { + id: "thread-1", + input: [{ type: "text", text: "saved locally", mentions: [] }], + }, + }), + ); + await waitFor(() => + expect(sdk.threads.queuedMessages.create).toHaveBeenCalledTimes(1), + ); + expect(getPendingThreadMessages()).toHaveLength(1); + await waitFor(() => expect(getPendingThreadMessages()).toHaveLength(0), { + timeout: 3000, + }); + const calls = vi.mocked(sdk.threads.queuedMessages.create).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[1]![0].clientSubmissionId).toBe(calls[0]![0].clientSubmissionId); + if (operation !== "queue") { + expect(sdk.threads.send).toHaveBeenCalledWith( + expect.objectContaining({ + mode: operation === "steer" ? "steer-if-active" : "queue-if-active", + }), + ); + } + queryClient.clear(); + }, +); diff --git a/apps/app/src/lib/PendingThreadMessagesSync.tsx b/apps/app/src/lib/PendingThreadMessagesSync.tsx index cd9eb0196a9..3703e81af8a 100644 --- a/apps/app/src/lib/PendingThreadMessagesSync.tsx +++ b/apps/app/src/lib/PendingThreadMessagesSync.tsx @@ -45,7 +45,10 @@ function PendingDelivery({ entry }: { entry: PendingThreadMessage }) { .send({ ...request, threadId: id, - mode: "queue-if-active", + mode: + entry.operation === "steer" + ? "steer-if-active" + : "queue-if-active", signal, }) .then((result) => diff --git a/apps/app/src/lib/pending-thread-messages.ts b/apps/app/src/lib/pending-thread-messages.ts index d8baab1c338..f3390fae407 100644 --- a/apps/app/src/lib/pending-thread-messages.ts +++ b/apps/app/src/lib/pending-thread-messages.ts @@ -11,7 +11,7 @@ import type { QueryClient } from "@tanstack/react-query"; const prefix = "bb.pending-thread-message.v1."; const entrySchema = z.object({ - operation: z.enum(["send", "queue"]), + operation: z.enum(["send", "queue", "steer"]), request: createQueuedMessageRequestSchema.extend({ id: z.string(), clientSubmissionId: z.string(), diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index dedef534b1a..9016d89e38d 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1365,6 +1365,25 @@ export function ThreadDetailPromptArea({ } if (shortcutRequest.kind === "draft") { + try { + if ( + pendingMessages.retain({ + request: shortcutRequest.request, + operation: "steer", + }) + ) { + promptDraft.clearIfCurrentMatches(submittedDraft); + setBottomAttachmentError(null); + return; + } + } catch (error) { + showMutationErrorToast({ + error, + fallbackMessage: "Could not save message on this device", + lifecycleOperation: "send_message", + }); + return; + } promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); await runWhileFollowUpShortcutSending( @@ -1405,6 +1424,7 @@ export function ThreadDetailPromptArea({ currentPromptDraft, currentPromptDraftInput, followUpExecutionSelection, + pendingMessages, promptDraft, queuedMessagesRef, sendMessage, diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 1b6dba012de..676e8d3cbcc 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -190,6 +190,7 @@ export interface CreateQueuedMessageForThreadArgs { payload: CreateQueuedMessageRequest; thread: Thread; startWhenIdle?: boolean; + steerWhenActive?: boolean; } function admitQueuedMessage( @@ -225,7 +226,11 @@ export async function createQueuedMessageForThread( const submissionId = payload.clientSubmissionId; const fingerprint = createHash("sha256") .update( - JSON.stringify({ payload, startWhenIdle: args.startWhenIdle === true }), + JSON.stringify({ + payload, + startWhenIdle: args.startWhenIdle === true, + ...(args.steerWhenActive ? { steerWhenActive: true } : {}), + }), ) .digest("hex"); const readReceipt = (db: DbQueryConnection): ThreadQueuedMessage | null => { @@ -302,12 +307,13 @@ export async function createQueuedMessageForThread( // and runs when the stop lands rather than joining the rows the // manual-stop pause holds back. waitingOn: - args.startWhenIdle && - (currentThread.status === "idle" || - currentThread.status === "error") - ? null - : currentThread.status === "stopping" - ? { kind: "stopping" } + currentThread.status === "stopping" + ? { kind: "stopping" } + : args.steerWhenActive || + (args.startWhenIdle && + (currentThread.status === "idle" || + currentThread.status === "error")) + ? null : { kind: "thread-busy" }, sendAt: null, payload: { kind: "inline" }, diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index f519d5f911a..f985097c05c 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -22,7 +22,9 @@ export async function acceptThreadSendRequest( if (args.payload.clientSubmissionId !== undefined) { const { mode, pluginSubmission, sendAt, ...payload } = args.payload; if ( - (mode !== "start" && mode !== "queue-if-active") || + (mode !== "start" && + mode !== "queue-if-active" && + mode !== "steer-if-active") || pluginSubmission !== undefined || sendAt !== undefined || payload.input.some( @@ -48,6 +50,7 @@ export async function acceptThreadSendRequest( thread: args.thread, payload, startWhenIdle: true, + steerWhenActive: mode === "steer-if-active", }), }; } diff --git a/apps/server/test/threads/thread-send-dispatch.test.ts b/apps/server/test/threads/thread-send-dispatch.test.ts index 06210a354cf..8247e8a998f 100644 --- a/apps/server/test/threads/thread-send-dispatch.test.ts +++ b/apps/server/test/threads/thread-send-dispatch.test.ts @@ -1391,6 +1391,68 @@ describe("service tier execution lifecycle", () => { }); }); + it("delivers a keyed steer into the active turn once while ordinary queued input waits", async () => { + await withTestHarness(async (harness) => { + const { environment, thread } = seedProviderThreadFixture({ + harness, + value: 182, + status: "active", + }); + seedTurnStarted(harness.deps, { + environmentId: environment.id, + providerThreadId: "provider-send-dispatch-182", + threadId: thread.id, + turnId: "turn-keyed-steer", + }); + const waiting = await createQueuedMessageForThread(harness.deps, { + thread, + payload: { input: textInput("after this turn") }, + }); + const args = { + thread, + payload: { + input: textInput("change direction now"), + mode: "steer-if-active" as const, + clientSubmissionId: "steer-submission", + }, + }; + const [first, retry] = await Promise.all([ + acceptThreadSendRequest(harness.deps, args), + acceptThreadSendRequest(harness.deps, args), + ]); + expect(retry).toEqual(first); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ + { id: waiting.id }, + ]); + expect( + listQueuedThreadCommands(harness, "turn.submit", thread.id), + ).toMatchObject([ + { + input: textInput("change direction now"), + target: { mode: "auto", expectedTurnId: "turn-keyed-steer" }, + }, + ]); + expect(await acceptThreadSendRequest(harness.deps, args)).toEqual(first); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect( + listQueuedThreadCommands(harness, "turn.submit", thread.id), + ).toHaveLength(1); + await expect( + acceptThreadSendRequest(harness.deps, { + ...args, + payload: { ...args.payload, mode: "queue-if-active" }, + }), + ).rejects.toThrow("already used"); + }); + }); + it("keeps queued choices separate until dispatch and preserves the next row", async () => { await withTestHarness(async (harness) => { const { thread } = seedProviderThreadFixture({ diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index cbf8d404056..33a4d0658a2 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -360,9 +360,10 @@ Queued messages: leave it blank. The SDK and `--json` include `initiator` and `senderThreadId`. Reuse --submission-id with the same message when retrying queue create or - thread tell --mode queue after a lost response. The server recognizes the - submission even after dispatch. Keys are scoped to a thread and do not - support steering, commands, or scheduled sends. + thread tell --mode queue or --mode steer after a lost response. The server + recognizes the submission even after dispatch. Keys are scoped to a thread and do not + support commands or scheduled sends. Steering joins the active turn when + possible; ordinary queued messages wait for that turn to finish. A queued message is one that could not dispatch yet. Every one carries a typed reason in its `Waiting on` column: waiting for the current turn to diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md index b208d0e5960..ee458a3cecc 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-operation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-operation.md @@ -1,6 +1,6 @@ # Thread coordination and inspection -For duplicate-safe retries of ordinary messages, use `bb thread tell ID MESSAGE --mode queue --submission-id KEY` or `bb thread queue create ID MESSAGE --submission-id KEY`. Reuse the same key and payload for retries on the same thread, including after a response is lost. Keys remain recognized after the queued message has dispatched; different content with the same key is rejected. Submission keys do not apply to steering, commands, or scheduled sends. +For duplicate-safe retries of ordinary messages, use `bb thread tell ID MESSAGE --mode queue --submission-id KEY` or `bb thread queue create ID MESSAGE --submission-id KEY`. Use `--mode steer` to join the active turn when possible. Reuse the same key, mode, and payload for retries on the same thread, including after a response is lost. Keys remain recognized after the queued message has dispatched; different content or delivery intent with the same key is rejected. Submission keys do not apply to commands or scheduled sends. ## Coordinating Work From cc9a1dcf0847cc378c0e9776388a642fd5c22b62 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 09:41:55 -0700 Subject: [PATCH 26/29] test: cover keyed send resume and overlapping steering --- .../threads/requested-queue-drain.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/apps/server/test/threads/requested-queue-drain.test.ts b/apps/server/test/threads/requested-queue-drain.test.ts index 284c555e894..fcbb3eb1be0 100644 --- a/apps/server/test/threads/requested-queue-drain.test.ts +++ b/apps/server/test/threads/requested-queue-drain.test.ts @@ -1,10 +1,12 @@ import { createQueuedThreadMessage, + getQueuedThreadMessage, listEvents, listQueuedThreadMessages, setQueuedThreadMessageFailureReason, setQueuedThreadMessageGroupBoundary, } from "@bb/db"; +import { createDeferredPromise } from "@bb/test-helpers"; import type { PluginHookName } from "@get-bb/plugin-sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -451,6 +453,125 @@ describe("the requested queue drain", () => { }, ); + it.each([false, true])( + "resumes a fresh keyed Send after Stop while respecting plugin hold=%s", + async (held) => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: `host-keyed-resume-${held}`, + status: "active", + }); + const older = seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: textInput("Work queued before Stop"), + waitingOn: { kind: "thread-busy" }, + }); + await stopThread(harness, thread.id); + vi.useFakeTimers(); + let hold = held; + let hookCalls = 0; + installHooks({ + "message.dispatch": [{ + pluginId: "limiter", + handler: () => { + hookCalls += 1; + return hold + ? { action: "wait", reason: "At capacity" } as const + : { action: "proceed" } as const; + }, + }], + }); + const args = { + thread, + payload: { + input: textInput("Resume this thread"), + mode: "queue-if-active" as const, + clientSubmissionId: "resume-after-stop", + }, + }; + const before = turnRequests(harness, thread.id).length; + const accepted = await acceptThreadSendRequest(harness.deps, args); + await runQueuedMessageDispatch(harness.deps, { + kind: "thread-ready", + threadId: thread.id, + }); + expect(hookCalls).toBe(1); + expect(turnRequests(harness, thread.id)).toHaveLength(before + (held ? 0 : 1)); + expect(await acceptThreadSendRequest(harness.deps, args)).toEqual(accepted); + expect(hookCalls).toBe(1); + if (held) { + expect(listQueuedThreadMessages(harness.db, thread.id)[1]?.waitingOn) + .toBe(JSON.stringify({ kind: "plugin", pluginId: "limiter", reason: "At capacity" })); + hold = false; + vi.advanceTimersByTime(1_001); + await runPluginWake(harness); + } + expect(turnRequests(harness, thread.id)).toHaveLength(before + 1); + expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ + { id: older.id, claimedAt: null }, + ]); + }); + }, + ); + + it("delivers overlapping distinct keyed Steers without another dispatch wake", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedRunnableThread(harness, { + hostId: "host-overlapping-keyed-steers", + status: "active", + }); + const older = seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: textInput("After the active turn"), + waitingOn: { kind: "thread-busy" }, + }); + const entered = createDeferredPromise(); + const release = createDeferredPromise(); + installHooks({ + "message.dispatch": [{ + pluginId: "barrier", + handler: async () => { + entered.resolve(); + await release.promise; + return { action: "proceed" } as const; + }, + }], + }); + const before = turnRequests(harness, thread.id).length; + const send = (id: string) => acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput(id), + mode: "steer-if-active", + clientSubmissionId: id, + }, + }); + const first = send("steer-a"); + await entered.promise; + const second = send("steer-b"); + const third = send("steer-c"); + try { + await vi.waitFor(() => { + for (const id of ["steer-a", "steer-b", "steer-c"]) { + expect(getQueuedThreadMessage(harness.db, `qmsg_${thread.id}_${id}`)) + .not.toBeNull(); + } + }); + } finally { + release.resolve(); + } + await Promise.all([first, second, third]); + await vi.waitFor(() => + expect(turnRequests(harness, thread.id)).toHaveLength(before + 3), + ); + await send("steer-b"); + expect(turnRequests(harness, thread.id)).toHaveLength(before + 3); + expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ + { id: older.id }, + ]); + }); + }); + it("resumes host-offline work without releasing ordinary work paused by Stop", async () => { await withTestHarness(async (harness) => { const { thread, environment } = seedRunnableThread(harness, { From b926457f3b63b4565e79e41dcbc858ee524d7f29 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 09:44:18 -0700 Subject: [PATCH 27/29] fix: dispatch fresh keyed submissions through single-row claims --- .../thread-detail/ThreadDetailPromptArea.tsx | 9 +-- .../src/services/threads/queued-messages.ts | 44 +++++++++-- .../threads/requested-queue-drain.test.ts | 79 +++++++++++-------- .../test/threads/thread-send-dispatch.test.ts | 2 +- 4 files changed, 86 insertions(+), 48 deletions(-) diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 9016d89e38d..1c4f2ac1d50 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1220,13 +1220,8 @@ export function ThreadDetailPromptArea({ if (!isQueuingMessage) promptDraft.clearIfCurrentMatches(submittedDraft); setBottomAttachmentError(null); if (isQueuingMessage) { - const request = buildCreateQueuedFollowUpRequest({ - threadId: thread.id, - input: submittedInput, - execution: followUpExecutionSelection, - }); - if (request) { - await createQueuedMessage.mutateAsync(request); + if (retainedRequest) { + await createQueuedMessage.mutateAsync(retainedRequest); promptDraft.clearIfCurrentMatches(submittedDraft); } } else { diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 676e8d3cbcc..8e74b617851 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { claimNextQueuedThreadMessageGroup, + claimQueuedThreadMessage, claimQueuedThreadMessageGroup, createQueuedThreadMessageInTransaction, deleteClaimedQueuedThreadMessageBatchInTransaction, @@ -118,7 +119,7 @@ type ClaimedQueuedMessage = Exclude< >[number]; interface SendClaimedQueuedMessageArgs { - mode: SendQueuedMessageMode; + mode: SendMessageRequest["mode"]; queuedMessages: ClaimedQueuedMessage[]; /** True for an explicit "send now"; false for an ordinary drain. */ sendNow: boolean; @@ -126,7 +127,7 @@ interface SendClaimedQueuedMessageArgs { } interface SendClaimedQueuedMessageForThreadArgs { - mode: SendQueuedMessageMode; + mode: SendMessageRequest["mode"]; queuedMessages: ClaimedQueuedMessage[]; sendNow: boolean; thread: Thread; @@ -352,10 +353,39 @@ export async function createQueuedMessageForThread( providerId: thread.providerId, }); } - if ( - (currentThread.status === "idle" && hasProviderSession) || - args.startWhenIdle - ) { + if (args.startWhenIdle) { + const claimed = claimQueuedThreadMessage( + deps.db, + deps.hub, + queuedMessage.id, + ); + if (claimed) { + try { + await withActiveQueuedMessageClaims([claimed], () => + sendClaimedQueuedMessage(deps, { + mode: args.steerWhenActive ? "steer-if-active" : "queue-if-active", + queuedMessages: [claimed], + sendNow: false, + threadId: thread.id, + }), + ); + } catch (error) { + releaseQueuedMessageClaims(deps, [claimed]); + if ( + !isQueuedMessageClaimLostError(error) && + !isQueuedMessageAutoSendPausedError(error) && + !(error instanceof ThreadContextClearInProgressError) && + !isCommandTimeoutError(error) + ) { + recordQueuedMessageDrainFailure(deps, { + error, + row: claimed, + thread: currentThread, + }); + } + } + } + } else if (currentThread.status === "idle" && hasProviderSession) { requestQueuedMessageDispatch(deps, { kind: "thread-ready", threadId: thread.id, @@ -395,7 +425,7 @@ function respectsManualStopPause( function sendQueuedMessagePayload( queuedMessage: ThreadQueuedMessage, - mode: SendQueuedMessageMode, + mode: SendMessageRequest["mode"], senderThreadId: string | null, ): SendMessageRequest { return { diff --git a/apps/server/test/threads/requested-queue-drain.test.ts b/apps/server/test/threads/requested-queue-drain.test.ts index fcbb3eb1be0..2c0c82c9832 100644 --- a/apps/server/test/threads/requested-queue-drain.test.ts +++ b/apps/server/test/threads/requested-queue-drain.test.ts @@ -471,15 +471,17 @@ describe("the requested queue drain", () => { let hold = held; let hookCalls = 0; installHooks({ - "message.dispatch": [{ - pluginId: "limiter", - handler: () => { - hookCalls += 1; - return hold - ? { action: "wait", reason: "At capacity" } as const - : { action: "proceed" } as const; + "message.dispatch": [ + { + pluginId: "limiter", + handler: () => { + hookCalls += 1; + return hold + ? ({ action: "wait", reason: "At capacity" } as const) + : ({ action: "proceed" } as const); + }, }, - }], + ], }); const args = { thread, @@ -491,17 +493,24 @@ describe("the requested queue drain", () => { }; const before = turnRequests(harness, thread.id).length; const accepted = await acceptThreadSendRequest(harness.deps, args); - await runQueuedMessageDispatch(harness.deps, { - kind: "thread-ready", - threadId: thread.id, - }); expect(hookCalls).toBe(1); - expect(turnRequests(harness, thread.id)).toHaveLength(before + (held ? 0 : 1)); - expect(await acceptThreadSendRequest(harness.deps, args)).toEqual(accepted); + expect(turnRequests(harness, thread.id)).toHaveLength( + before + (held ? 0 : 1), + ); + expect(await acceptThreadSendRequest(harness.deps, args)).toEqual( + accepted, + ); expect(hookCalls).toBe(1); if (held) { - expect(listQueuedThreadMessages(harness.db, thread.id)[1]?.waitingOn) - .toBe(JSON.stringify({ kind: "plugin", pluginId: "limiter", reason: "At capacity" })); + expect( + listQueuedThreadMessages(harness.db, thread.id)[1]?.waitingOn, + ).toBe( + JSON.stringify({ + kind: "plugin", + pluginId: "limiter", + reason: "At capacity", + }), + ); hold = false; vi.advanceTimersByTime(1_001); await runPluginWake(harness); @@ -528,24 +537,27 @@ describe("the requested queue drain", () => { const entered = createDeferredPromise(); const release = createDeferredPromise(); installHooks({ - "message.dispatch": [{ - pluginId: "barrier", - handler: async () => { - entered.resolve(); - await release.promise; - return { action: "proceed" } as const; + "message.dispatch": [ + { + pluginId: "barrier", + handler: async () => { + entered.resolve(); + await release.promise; + return { action: "proceed" } as const; + }, }, - }], + ], }); const before = turnRequests(harness, thread.id).length; - const send = (id: string) => acceptThreadSendRequest(harness.deps, { - thread, - payload: { - input: textInput(id), - mode: "steer-if-active", - clientSubmissionId: id, - }, - }); + const send = (id: string) => + acceptThreadSendRequest(harness.deps, { + thread, + payload: { + input: textInput(id), + mode: "steer-if-active", + clientSubmissionId: id, + }, + }); const first = send("steer-a"); await entered.promise; const second = send("steer-b"); @@ -553,8 +565,9 @@ describe("the requested queue drain", () => { try { await vi.waitFor(() => { for (const id of ["steer-a", "steer-b", "steer-c"]) { - expect(getQueuedThreadMessage(harness.db, `qmsg_${thread.id}_${id}`)) - .not.toBeNull(); + expect( + getQueuedThreadMessage(harness.db, `qmsg_${thread.id}_${id}`), + ).not.toBeNull(); } }); } finally { diff --git a/apps/server/test/threads/thread-send-dispatch.test.ts b/apps/server/test/threads/thread-send-dispatch.test.ts index 8247e8a998f..fac5e6ebc97 100644 --- a/apps/server/test/threads/thread-send-dispatch.test.ts +++ b/apps/server/test/threads/thread-send-dispatch.test.ts @@ -1433,7 +1433,7 @@ describe("service tier execution lifecycle", () => { ).toMatchObject([ { input: textInput("change direction now"), - target: { mode: "auto", expectedTurnId: "turn-keyed-steer" }, + target: { mode: "steer", expectedTurnId: "turn-keyed-steer" }, }, ]); expect(await acceptThreadSendRequest(harness.deps, args)).toEqual(first); From 7171103689eeef0095ac5d4a2d9af9eac80cdb7e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sun, 20 Sep 2026 10:41:02 -0700 Subject: [PATCH 28/29] Clarify queued messages awaiting server connection --- .../promptbox/banner/QueuedMessagesList.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 791b538af3d..98fccfe31a0 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -716,7 +716,7 @@ function QueuedMessageWaitLine({ ); const now = useSecondTick(); const label = pending - ? (pending.error ?? "Waiting for server confirmation") + ? (pending.error ?? "Connecting to server") : describeQueuedMessageWait({ failureReason: queuedMessage.failureReason, now, @@ -727,7 +727,11 @@ function QueuedMessageWaitLine({ }); if (label === null) return null; const failed = pending?.error != null || queuedMessage.failureReason !== null; - const icon = queuedMessageWaitIcon(queuedMessage); + const icon = pending + ? failed + ? "AlertCircle" + : "Spinner" + : queuedMessageWaitIcon(queuedMessage); const countdownInstant = queuedMessageCountdownInstant(queuedMessage); const countdown = countdownInstant === null @@ -743,7 +747,11 @@ function QueuedMessageWaitLine({ )} > {icon !== null ? ( - + ) : queuedMessage.waitingOn?.kind === "plugin" ? ( Date: Sun, 20 Sep 2026 11:13:10 -0700 Subject: [PATCH 29/29] Use the standard loading icon for pending messages --- apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 98fccfe31a0..7da4e25b28f 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -730,7 +730,7 @@ function QueuedMessageWaitLine({ const icon = pending ? failed ? "AlertCircle" - : "Spinner" + : "Loading" : queuedMessageWaitIcon(queuedMessage); const countdownInstant = queuedMessageCountdownInstant(queuedMessage); const countdown =